kyo-stats-machine
kyo-stats-machine has no Scala call site. There is no Machine.start, no handle to hold, no scope to thread. The first useful thing about this module is a build.sbt line: add it to any JVM, JS, Native, or Wasm module that already runs kyo-core, and a detached fiber wakes once a second to read host CPU time, memory, swap, per-mount disk space, load average, and, on Linux, cgroup and PSI pressure, observing every reading directly into kyo.Stat counters, histograms, and gauges under the machine. scope.
That makes this module a monitoring contract, not an API. Three things describe it completely: what turns it on (classpath presence), what turns it off (one opt-out lever), and what it emits (a fixed metric taxonomy you query through whatever stats backend your application already wires, such as kyo-stats-otlp). What varies by OS and platform is a metric quietly missing from the exported set, never a fake zero and never a thrown error.
Adding the module
// build.sbt
// libraryDependencies += "org.getkyo" %% "kyo-stats-machine" % "<version>"That line is the entire integration for a kyo.KyoApp. There is no import to add and no method to call: the application entrypoint reaches kyo.Stat before running your code, the service loader discovers MachineStatFactory, and constructing that factory starts the sampler.
A host that is not a KyoApp (a kyo library embedded in an application that is not kyo's, or an entrypoint of your own) has no such moment, and nothing starts. Call Stat.activate() once at startup there; it is idempotent and does nothing else. kyo-core deliberately activates nowhere else: the only hook broad enough to cover every process is the fiber-creation path, and a stats concern does not belong in the scheduler's hot path.
Note: the sampler is a one-shot, process-lifetime singleton. Once it starts, it runs for the life of the process; there is no stop call, and adding the dependency a second time or touching Stat again does not start a second sampler.
The sampler ticks on a drift-corrected schedule (Clock.repeatAtInterval(Schedule.anchored(interval)), anchored so a slow tick does not push the next one late) for as long as the process runs. The cadence is one second unless KYO_MACHINE_INTERVAL overrides it (see below). It is unrelated to how often your metrics backend ships those readings off the process: an exporter like kyo-stats-otlp scrapes the kyo.Stat registry on its own schedule (OTEL_METRIC_EXPORT_INTERVAL, 60 seconds by default), so a 1-second sampling resolution can still be exported once a minute. If you want finer-grained visibility into the emitted histograms, lower the exporter's interval.
One sampler serves every consumer in the process, which is why the cadence is the producer's to set and not a consumer's: polling the registry faster than the sampler ticks reads the same values again. A dashboard that needs sub-second host behaviour raises the producer's rate.
Note: kyo-stats-machine is a producer only. Adding it with no exporter on the classpath records every metric into the in-process registry, but nothing ships anywhere observable. Pair it with an exporter module such as kyo-stats-otlp, or the effect of adding this dependency is invisible.
On the JVM, that is the whole story: the JVM discovers the factory through META-INF/services. On Scala.js, registration goes through the same @JSExportTopLevel mechanism kyo-stats-otlp uses: a single public object, MachineRegistration, calls JSServiceLoaderRegistry.register in a @JSExportTopLevel-annotated field. It is the one top-level symbol in this module that is not private[kyo], because the JS runtime has to be able to reach it. Scala Native needs one more step, covered below.
Caution: the Scala.js linker may tree-shake MachineRegistration if nothing references it, which drops the auto-load registration and disables sampling on JS with no error. If telemetry silently never starts on JS, force a reference (val _ = MachineRegistration.init) somewhere in your application's entry point, keeping the object retained.
Scala Native: a build-time precondition
Scala Native's ServiceLoader only discovers providers named in a build-time allowlist; dead-code elimination drops the factory class entirely if nothing in the build references it. On every other platform, adding the dependency is sufficient. On Native, the downstream application's build.sbt must also register the provider explicitly:
// build.sbt (Scala Native targets only)
nativeConfig ~= {
_.withServiceProviders(Map(
"kyo.stats.internal.ExporterFactory" -> Seq("kyo.stats.machine.MachineStatFactory")
))
}Caution: without this line, kyo-stats-machine compiles and links cleanly on Native but never samples anything. There is no error, no warning, and no metric: the sampler simply never starts. This is the one platform where adding the library dependency is not enough by itself.
Turning it off, and changing the rate
The sampler has two levers, both read once at startup.
The first is the opt-out, read when the factory is constructed.
export KYO_MACHINE_DISABLED=trueor the equivalent system property:
// -Dkyo.machine.disabled=trueEither suppresses the sampler start. Both are read exactly once, at registration time; there is no runtime toggle. Setting the variable after the process has already started the sampler has no effect, because the read already happened.
Note: an unset or unparseable value enables the sampler. This is a fail-open default, not a fail-safe one: if you meant to disable monitoring and misspelled the variable name or the value, the sampler starts anyway. Only the literal string "true" (case-insensitive) disables it.
The second is the sample interval, read when the sampler starts. It takes a duration, so 100ms, 2s and 1 minute all work.
export KYO_MACHINE_INTERVAL=100msor the equivalent system property:
// -Dkyo.machine.interval=100msUnset, the cadence is one second, which is the right default: one shared sampler at 1 Hz costs the host far less than every consumer reading /proc for itself.
A third lever, KYO_MACHINE_DISKREADTIMEOUT (kyo.machine.diskReadTimeout, four seconds by default), bounds how long a single disk read may take before its cycle is abandoned. It applies only to the disk fiber; the other families never wait on it. Keep it above the sample interval, since a bound at or below the cadence abandons every slow mount before it can answer.
All three are kyo.config static flags: they resolve once at class load, so a change needs a restart, and an unparseable value fails fast at startup rather than being silently ignored.
Reading the metrics
You consume machine.* the same way you consume any other kyo.Stat metric your application records: through whatever stats backend or exporter you already have wired, using machine as the scope root. The scope root is a plain string, independent of the module's Scala package (kyo.stats.machine) and independent of an exporter's own operational scope. If you pair this module with kyo-stats-otlp, for example, that exporter records its own health counters under kyo.stats.otel.*; keep the two prefixes distinct when you query a backend, since one is host telemetry this module produces and the other is the exporter's own health telemetry.
To read a metric back inside the process, use MachineMetrics, which names every key in the taxonomy:
// Unsafe: the registry's read tier is the unsafe one; summary() is a plain value read.
import AllowUnsafe.embrace.danger
import kyo.*
import kyo.stats.machine.MachineMetrics
val cpuNanosPerSecond: Maybe[Double] =
MachineMetrics.cpuTotalRate.findHistogram.map(_.summary().max)
val totalMemory: Maybe[Double] =
MachineMetrics.memoryTotal.findGauge.map(_.collect())Two things about that are deliberate. The accessors are lookups, never registrations: a metric this host does not produce reads back as Absent rather than becoming a permanently empty series that an exporter would then publish. And the key is a value rather than a string you assemble, because a registry key is a List[String] and the dotted rendering the tables below use does not say where the scope ends: machine.cpu.total.rate is scope ["machine","cpu"] with the instrument named "total.rate", dot included, while machine.memory.available is scope ["machine","memory"] with the instrument named "available". The two render identically and split differently.
Two families are named at runtime rather than fixed, one entry per mounted filesystem and one per pressure resource-and-kind pair. Ask the registry what it actually has:
import kyo.stats.internal.StatsRegistry
import kyo.stats.machine.MachineMetrics
val mounts: Seq[String] = MachineMetrics.diskStores()
val everything: Seq[StatsRegistry.Registration] = StatsRegistry.snapshot("machine")StatsRegistry.snapshot is also how to answer "what does this host actually support?": a metric the running OS has no source for registers nothing at all, so absence from the snapshot is the answer.
Every metric in the taxonomy is one of three kyo.Stat types:
- Gauge: a point-in-time value read raw on each poll, with no delta. This is the largest group: fixed totals (core count, physical memory total, per-mount capacity), pre-averaged values (load average, PSI
avg10/60/300percentages), and the cgroup config values that can rise or fall at runtime. - Histogram: a bucketed distribution of per-tick observations. Some histograms observe a point-in-time level each tick (available/free memory and swap, per-mount free space); others observe a per-second delta (CPU usage, cgroup throttling, PSI stall time). A
.ratehistogram's running sum is its own cumulative total, exported under cumulative aggregation temporality so the lifetime total survives every flush, and a rate series therefore needs no separate counter. - Counter: monotonic and cumulative. Exactly one metric is a Counter:
machine.cgroup.cpu.periods, the scheduling-period count that has no per-second.ratecompanion. Every other cumulative signal is carried inside a.ratehistogram's sum.
The machine.* metric taxonomy
Every path below lives under the machine scope root. total, free, and available are always bytes; time is always stored in nanoseconds regardless of the OS's native unit, so per-source scaling (jiffies, microseconds, FILETIME 100ns units) happens once, in the sampler, not in every reader. Every cell registers its underlying kyo.Stat handle lazily, on its first present observation: a metric the host never produces never registers a handle and is simply absent from the exported set (never a fabricated zero).
CPU
Per-second CPU-usage histograms, plus the fixed core count. The histogram buckets are derived at init from the host's actual core count, so a many-core host does not funnel every observation into one overflow bucket.
| Path | Type | Unit | Notes |
|---|---|---|---|
machine.cpu.total.rate | Histogram | ns/s | sum carries cumulative ns |
machine.cpu.user.rate | Histogram | ns/s | sum carries cumulative ns |
machine.cpu.system.rate | Histogram | ns/s | sum carries cumulative ns; on Windows, kernel-minus-idle |
machine.cpu.idle.rate | Histogram | ns/s | sum carries cumulative ns |
machine.cpu.iowait.rate | Histogram | ns/s | Linux only; sum carries cumulative ns |
machine.cpu.steal.rate | Histogram | ns/s | Linux only; hypervisor-steal signal; sum carries cumulative ns |
machine.cpu.cores | Gauge | count | fixed for process lifetime, every OS |
machine.cpu.steal.rate is the per-second time a hypervisor gives this host's CPU to another tenant, distinguishing "my CPU is busy" from "my CPU was taken", and it exists on Linux only.
Memory and swap
Physical totals are Gauges (fixed for the process lifetime); the varying levels are observed into Histograms:
| Path | Type | Unit | Notes |
|---|---|---|---|
machine.memory.total | Gauge | bytes | physical memory total |
machine.memory.available | Histogram | bytes | Linux MemAvailable, macOS free+inactive, Windows ullAvailPhys |
machine.memory.free | Histogram | bytes | Linux, macOS; absent on Windows (no distinct free-vs-available concept) |
machine.swap.total | Gauge | bytes | Windows reports the commit limit here, the closest honest mapping |
machine.swap.free | Histogram | bytes | Windows reports the available commit here |
Disk
Per-mount, so the <store> segment is a dynamic scope, not a fixed name: two metrics fan out to every physical mount the sampler discovers. This pair is the only part of the taxonomy that is not a fixed count of leaves.
| Path | Type | Unit | Notes |
|---|---|---|---|
machine.disk.<store>.total | Gauge | bytes | capacity, one per physical mount |
machine.disk.<store>.free | Histogram | bytes | free space, one per physical mount |
<store> is a sanitized mount identity (kernel octal escapes in the mount path are decoded first). The mount set is physical filesystems only (network and virtual mounts are filtered out) and is rebuilt only when the mount table changes, not re-scanned per tick. The disk read runs on its own fiber the tick loop never awaits inline, so a slow or dead mount (a hung NFS export, for instance) never delays the fast CPU/memory/load reads of the same or any later tick.
Load average
Pre-averaged values, so each is a plain Gauge (a load average can rise or fall, and it is not a distribution to bucket):
| Path | Type | Unit | Notes |
|---|---|---|---|
machine.load.one | Gauge | load units | Linux, macOS; absent on Windows |
machine.load.five | Gauge | load units | Linux, macOS; absent on Windows |
machine.load.fifteen | Gauge | load units | Linux, macOS; absent on Windows |
cgroup
Linux only, read at the process's resolved cgroup path (resolved once at init from /proc/self/mountinfo and /proc/self/cgroup, transparently across v1 and v2). Memory is a usage Histogram plus a limit Gauge; CPU carries the quota/period config as Gauges, the period count as the one Counter, and throttling as per-second Histograms:
| Path | Type | Unit | Notes |
|---|---|---|---|
machine.cgroup.memory.usage | Histogram | bytes | |
machine.cgroup.memory.limit | Gauge | bytes (config) | Absent when unset or unlimited |
machine.cgroup.cpu.quota | Gauge | ns (config) | Absent when unset |
machine.cgroup.cpu.period | Gauge | ns (config) | |
machine.cgroup.cpu.periods | Counter | count (cumulative) | scheduling periods observed; the one unpaired Counter |
machine.cgroup.cpu.throttled.periods.rate | Histogram | periods/s | sum carries cumulative throttled-period count |
machine.cgroup.cpu.throttled.rate | Histogram | ns/s | sum carries cumulative throttled ns |
Note: machine.cgroup.memory.limit, machine.cgroup.cpu.quota, and machine.cgroup.cpu.period are plain Gauges, not the monotonic CounterGauge you might expect from their neighbors. A config value can rise or fall at runtime (a Kubernetes in-place resize lowering a memory limit, for instance), and a delta-based exporter reading a CounterGauge would map a decrease to a rolling-counter wraparound. The Gauge exports the raw value with no delta, which is the only correct reading for a value that is not monotonic. Each is seeded with its first real value before its handle registers, so its first poll is never a transient zero; an unset config value never registers a handle at all.
System pressure (PSI)
Linux only, read from /proc/pressure/{cpu,memory,io}. Each (resource, type) pair carries three pre-averaged EWMA gauges plus a per-second stall-rate histogram:
| Path pattern | Type | Unit | Notes |
|---|---|---|---|
machine.pressure.<res>.<type>.avg10 | Gauge | percent 0-100 | 10-second EWMA |
machine.pressure.<res>.<type>.avg60 | Gauge | percent 0-100 | 60-second EWMA |
machine.pressure.<res>.<type>.avg300 | Gauge | percent 0-100 | 300-second EWMA |
machine.pressure.<res>.<type>.rate | Histogram | ns/s | per-tick stall-time delta; sum carries cumulative stall ns |
<res> ranges over cpu, memory, io; <type> ranges over some, full. Five pairs are emitted: cpu.some, memory.some, memory.full, io.some, io.full. cpu.full is parsed but never emitted, since the kernel pins it at zero on kernels 5.13 and later and it carries no information.
cgroup pressure (PSI)
Linux cgroup v2 only, read from the resolved cgroup's own {cpu,memory,io}.pressure files. Same shape as system pressure, but a distinct scope, so a container's own pressure is never mixed with the host's:
| Path pattern | Type | Unit | Notes |
|---|---|---|---|
machine.cgroup.pressure.<res>.<type>.avg10 | Gauge | percent 0-100 | |
machine.cgroup.pressure.<res>.<type>.avg60 | Gauge | percent 0-100 | |
machine.cgroup.pressure.<res>.<type>.avg300 | Gauge | percent 0-100 | |
machine.cgroup.pressure.<res>.<type>.rate | Histogram | ns/s | per-tick stall-time delta; sum carries cumulative stall ns |
Same five (res, type) pairs as system pressure, same cpu.full exclusion. Absent on cgroup v1 hosts and off Linux entirely.
When you need host-wide pressure (contention across every process on the machine), read machine.pressure.*. When you need pressure scoped to your own container (contention your cgroup specifically experiences, which can differ sharply from the host under a busy neighbor), read machine.cgroup.pressure.*. Both can be present at once on a Linux cgroup v2 host, and they answer different questions.
Platform and OS coverage
Two independent axes decide what you see: the operating system decides which metric families exist, and the compile target decides whether the sampler runs at all.
By operating system
| Family | Linux | macOS | Windows |
|---|---|---|---|
| CPU total/user/system/idle rate | yes | yes | yes |
CPU iowait.rate | yes | Absent | Absent |
CPU steal.rate | yes | Absent | Absent |
CPU cores | yes | yes | yes |
| Memory total/available | yes | yes | yes |
Memory free | yes | yes | Absent |
| Swap total/free | yes | yes | yes (commit limit) |
| Disk (per mount) | yes | yes | yes |
| Load average | yes | yes | Absent |
| cgroup (memory/cpu) | yes | Absent | Absent |
| System PSI | yes | Absent | Absent |
| cgroup PSI | yes (cgroup v2) | Absent | Absent |
The structural absences are deliberate, and each is the absence of a real host concept, not a gap in coverage:
- Load average is Absent on Windows. Windows exposes no load-average equivalent, so the three
machine.load.*gauges never register there. - cgroup and PSI are Linux only. They are kernel features with no macOS or Windows counterpart, so every
machine.cgroup.*andmachine.pressure.*series is absent off Linux, and cgroup PSI additionally requires cgroup v2. - `cpu.steal.rate` and `cpu.iowait.rate` are Linux only. macOS
host_cpu_load_infohas no iowait bucket and neither macOS nor Windows exposes hypervisor steal time, so both series are written and registered only on Linux. - `memory.free` is Absent on Windows. Windows exposes only total and available physical memory (
GlobalMemoryStatusEx), with no distinct free-versus-available figure; reporting the available number again under afreelabel would be a real number under the wrong meaning, so the cell is never written there. - `cpu.full` pressure is never emitted, on any host. The kernel parses a
fullline for the CPU resource but pins it at zero, so nomachine.pressure.cpu.full.*ormachine.cgroup.pressure.cpu.full.*series is ever created. Memory and io emit bothsomeandfull; cpu emitssomeonly.
By compile target
| Target | Status |
|---|---|
| JVM | auto-loads via META-INF/services, no extra step |
| Scala.js (Node) | auto-loads via @JSExportTopLevel; native calls go through a koffi-backed FFI binding; keep MachineRegistration referenced so the linker does not tree-shake it (see the Caution above) |
| Scala.js (browser) | degrades: no koffi in a browser environment, so host reads are unavailable |
| Scala Native | requires the nativeConfig.withServiceProviders(...) build line above; auto-loads identically to JVM/JS once declared |
| Wasm (Node) | auto-loads via @JSExportTopLevel like Scala.js; native calls go through the same koffi-backed FFI binding, linked as ModuleKind.ESModule (koffi is imported through a default @JSImport, which the ESModule interop needs to reach a CommonJS addon's members). Requires Node 24+. Keep MachineRegistration referenced so the linker does not tree-shake it |
Note: every row above where a family or platform is unsupported means the metric is simply absent from the exported set, structurally, not just by documentation. An OS with no PSI support never creates the PSI handles at all; a browser-JS build never attempts the koffi-backed reads. You will never see a fake zero standing in for "not available here." A metric's total absence from what your backend receives is the signal that the current host or platform does not support it.
Demos
A runnable demo lives in shared/src/test/scala/demo. MachineStatsDemoApp is the auto-load story end to end: it names no metric and calls nothing, lets being a KyoApp start the sampler, waits a few ticks, then prints the machine.* families it read off this host and validates them.
It is a standalone main meant to run on your own classpath with kyo-stats-machine present: run it from, or copy it into, an application that depends on the module. This repository's own test configuration sets the KYO_MACHINE_DISABLED opt-out so the suites never race a live sampler, and under that lever the sampler stays off and the demo has nothing to report, so MachineStatFactoryJvmTest runs it in a forked JVM without the opt-out (and once more with it, as the control) rather than in the test process. Run it where the module is a normal dependency and it prints the host's metrics; set KYO_MACHINE_DISABLED=true on that run to watch the opt-out suppress the sampler. The demo's registry readback works on JVM and Scala Native, using their platform WeakReference implementations. On Native, include the service-provider configuration above. Scala.js and Wasm lack the java.lang.ref.Reference required by this readback helper; observe the module through your own stats backend on those targets instead. The test suites verify auto-load there.