Heimdall watches a mixed fleet (Apple/Intel/AMD/ARM, macOS/Linux/Windows). The whole point is that a metric means the same thing everywhere, no matter what the platform calls it. This guide is the rule for keeping it that way. If you are adding or changing a metric, read this first.
See ADR-0022 for the decision and the failure that motivated it, and ADR-0021 for the power example.
Adapters translate platform specifics into neutral metrics. The
domaincore turns neutral metrics into display-ready structures. Consumers (TUI, CLI, dashboard) render those structures and nothing more.
Knowledge flows one way: platform → adapter → neutral metric → domain → consumer. No platform names, no vendor branching, and no classification logic ever reach a renderer.
| Layer | Package | Owns | Must NOT contain |
|---|---|---|---|
| Adapters | app/internal/adapters, app/internal/helper |
reading each platform's own scheme and emitting a neutral metric | cross-host assumptions; UI concerns |
| Domain | app/internal/domain |
the neutral taxonomy + functions that turn neutral metrics into display-ready structures | any framework / transport / UI import |
| Consumers | app/internal/tui/*, app/internal/cli, dashboard |
rendering (colours, bars, boxes, JSON) | bucketing, classification, platform branching |
Each platform names core types differently:
- macOS:
hw.perflevel0/1sysctl counts. - Intel Linux:
/sys/devices/cpu_core/cpusandcpu_atom/cpusindex ranges. - Windows:
GetLogicalProcessorInformationExEfficiencyClassvalues.
Adapter (app/internal/adapters/cputopo_*.go) translates each into the neutral
taxonomy domain.CorePerf / CoreEff / CoreLP and emits one metric,
cpu.topology, whose PerCore[i] is the type id of logical core i. Platform
code stops here.
Domain (app/internal/domain/coretype.go) turns that neutral layout into
display-ready groups:
groups, ok := domain.CoreGroups(coreUtil, coreType) // []CoreGroup{Label, Indices, Util}
summary := domain.CoreTypeSummary(coreType) // "12P + 4E"Consumers call those and render:
- TUI top view: paints each group under a
P-cores (N):/E-cores (N):header. - CLI
host: serialises them ascore_groups(type, cores, util_pct).
Neither consumer buckets cores or knows what a "perflevel" or "EfficiencyClass" is. Add a fourth platform and only its adapter changes.
A per-core metric is transmitted as its PerCore slice via the protobuf
per_core oneof — so its scalar Gauge is dropped on the wire and
arrives as 0 at the dashboard/CLI.
- Never key logic off a per-core metric's
Gauge. Standardization functions takePerCore. - Carry any human summary in
Detail(which does cross the wire), ascpu.topologydoes. - This is exactly why the P/E grid silently showed the plain grid on every host
in an early cut: the grouping read
Gauge, which the wire had zeroed. It passed locally becauseGaugeis set in-process. Add a transport round-trip test for any new per-core metric.
- Adapter per platform (build-tagged): read the native source, emit a neutral metric. No platform strings in the value.
- Pure parser, build-tag-free, unit-tested on any OS (split the shell-out / syscall from the parsing).
- If consumers need a computed view (grouping, classification, a summary), put
it in
domainas a pure function — never in a renderer. - Consumers call the domain function and render. If you find yourself writing a
switchon platform or bucketing values in a TUI/CLI file, stop — it belongs in an adapter ordomain. - Per-core metric? Data goes in
PerCore, summary inDetail, and add a transport round-trip test. Never rely onGauge. - Explain gaps: a platform that can't supply the metric returns
Unavailable-with-reason (ADR-0003), never a fabricated value.