How mectrics is put together and why. This assumes general application experience rather than familiarity with AppKit, IOKit, or the SMC.
The metric engine is a UI-independent Swift package. Everything that touches the machine lives there; everything that touches the screen lives in the app target. That is what makes the engine testable, keeps a misbehaving provider away from the UI, and lets the widget extension read the same data.
flowchart LR
EG["<b>EnergyGuard</b><br/><i>power source</i><br/><i>Low Power Mode</i><br/><i>thermal state</i>"]
subgraph kit["MetricsKit — SwiftPM package, no UI and no localization"]
direction TB
SS["<b>SamplingScheduler</b><br/><i>adaptive, cost-aware</i>"]
PR["<b>MetricProvider</b> × 8<br/>CPU · Memory · Battery · Network · Disk<br/>GPU · Sensors · Fans"]
ST["<b>MetricStore</b><br/><i>pre-allocated ring buffer</i>"]
AE["<b>Alert evaluators</b><br/><i>thresholds · system conditions</i>"]
SS --> PR --> ST
ST --> AE
end
subgraph app["Mectrics.app — LSUIElement agent, no Dock icon"]
AM["<b>AppModel</b><br/><i>@Observable</i>"]
MB["<b>MenuBarController</b><br/>one NSStatusItem per component"]
PO["<b>Popovers</b><br/>details · Compact Health · Settings"]
AL["<b>Alert delivery</b><br/>notifications · Compact Health · Attention Log"]
AM --> MB
AM --> PO
AM --> AL
end
WG["<b>MectricsWidget</b><br/><i>WidgetKit extension</i>"]
CLI["<b>mectrics CLI</b><br/><i>check · snapshot · watch · doctor</i>"]
EG -->|"sampling policy"| SS
ST -->|"snapshot"| AM
ST -->|"JSON via App Group"| WG
AE -->|"text or NDJSON"| CLI
| Layer | Choice | Why |
|---|---|---|
| Language | Swift 6 (MetricsKit builds in the Swift 6 language mode) | Native, low resource use; strict concurrency checks the queue boundaries the engine crosses. |
| UI | SwiftUI + AppKit hybrid | SwiftUI for settings, popovers, and windows. AppKit (NSStatusItem) for the menu bar — pure SwiftUI MenuBarExtra cannot do the custom drawing a live sparkline needs. |
| Menu bar | NSStatusItem + rendered NSImage |
Live text and sparkline, ⌘-drag reordering, pixel-precise width control. |
| Widget | WidgetKit extension | System-native but throttled to minutes; the menu bar covers the real-time need. |
| Metric engine | Local Swift package MetricsKit |
Testable, UI-independent, shared with the widget extension. |
| Sharing | App Group container | Main app ↔ widget extension data hand-off. |
| Reactivity | @Observable |
sampler → store → app model → UI. |
| Persistence | UserDefaults (App Group) + a small JSON snapshot | Settings plus the widget's last snapshot. No database needed. |
| Updates | Sparkle | The standard for directly distributed macOS apps. |
| Login item | SMAppService |
The modern launch-at-login API. |
Minimum target: macOS 15 Sequoia, on Apple silicon and Intel. Modern APIs
(@Observable, current WidgetKit) are therefore freely usable. Intel support is not a
separate code path — it lives in the providers, which read whichever SMC key family and
accelerator driver the machine happens to expose.
// A single measurement.
struct MetricSample {
let timestamp: Date
let value: Double // normalized base value (e.g. CPU load as a fraction)
let detail: [String: Double] // per-core, used/wired, up/down, and so on
}
protocol MetricProvider: AnyObject, Sendable {
var id: MetricID { get } // .cpu, .memory, ...
var isAvailable: Bool { get } // hardware and permission present?
var cost: SamplingCost { get } // light / medium / heavy → scheduling weight
func sample() -> MetricSample?
}
final class MetricStore {
// A fixed-size ring buffer per metric, pre-allocated.
func append(_ sample: MetricSample, for id: MetricID)
func latest(_ id: MetricID) -> MetricSample?
func history(_ id: MetricID, count: Int) -> [MetricSample] // sparklines
}Providers are sampled on a single serial queue, which is why MetricProvider requires
Sendable and the concrete providers are @unchecked Sendable: they wrap C-based
Mach/IOKit state and own their synchronization. Anything read outside that queue must be
guarded by a lock.
A provider that returns isAvailable = false is filtered out at registration, so its module
disappears from the UI rather than showing an empty row.
| Metric | Source | Pitfall |
|---|---|---|
| CPU | host_processor_info(PROCESSOR_CPU_LOAD_INFO) |
Usage is the difference between two consecutive samples; per-core comes from the same call. |
| Memory | host_statistics64(vm_statistics64) + sysctl(hw.memsize) |
Pressure and compressed pages are separate from plain "used". |
| Battery | IOKit IOPSCopyPowerSourcesInfo; health and cycles from IORegistry AppleSmartBattery |
Two different sources feed one module. |
| Network | sysctl(NET_RT_IFLIST2) + if_data64 |
Δbytes/Δt. 64-bit counters are required — 32-bit ones wrap on a long uptime. Loopback is filtered by IFF_LOOPBACK. |
| Disk space | statfs, plus URL.resourceValues for reclaimable space |
statfs every sample; the reclaimable figure goes through the cache-deletion daemon and is refreshed once a minute. |
| Disk throughput | IOKit IOBlockStorageDriver statistics |
Δ read/write bytes. |
| GPU | IOKit accelerator "Device Utilization %"; VRAM via IORegistry | Keys differ across Apple Silicon generations. |
| Temperature | SMC key reads grouped into CPU, GPU, and Memory hardware domains | The SMC key set differs per machine — treat every key as optional. |
| Fans | SMC FNum and F*Ac keys |
Some Macs have no fans at all; speed keys provide a fallback when the count key is unavailable. |
All of these are read-only. Reading sensors, SMC, fans, and GPU is restricted under the App Store sandbox, which is why full functionality requires direct distribution with a Developer ID and notarization.
Four alert conditions are not thresholds on a sampled number but states macOS reports
directly. SystemConditionSource resolves all four from one sampling pass, so the app, the
CLI, and the one-shot health report read them identically.
| Condition | Source | Why it is not a number rule |
|---|---|---|
| Thermal pressure | ProcessInfo.thermalState |
A temperature says how hot a part is; this says whether the machine is being slowed down. On Apple silicon the state covers the whole package, so a throttled GPU is included — there is no separate public GPU signal, and reading clock frequencies would mean private interfaces (IOReport) that put notarization at risk. |
| Memory pressure | sysctl(kern.memorystatus_vm_pressure_level) |
Memory can sit at 95% used with the kernel under no pressure at all. The percentage cannot tell a healthy full machine from a struggling one. |
| Disk capacity | statfs free bytes, via the disk provider's detail |
Free space is already the right signal; there is nothing for macOS to judge. |
| Battery service | IOKit's service-recommended flag | A hardware fault macOS has decided on, not a reading to compare. |
Thermal pressure is the only one with no provider behind it, so it stays readable when a sampling cycle turns up nothing. Its rule is filed under the CPU module for grouping and for opening the right detail window; its wording names the GPU as well.
Both state conditions run through the same sustained-duration and cooldown machinery as the
number rules (SystemConditionMonitor): a state has to persist for the rule's window before
it alerts, because a build that pushes the chip into throttling for twenty seconds is
ordinary work, not news. EnergyGuard reads the same thermal enum it uses to slow its own
sampling, so Mectrics eases off on exactly the states it would report.
- One
NSStatusItemper enabled component, not per module — Battery can contribute its icon and its health as two independent items. - CPU, Memory, and GPU can each contribute an independent temperature item when the Mac reports a recognized sensor for that hardware domain.
- Each item renders text plus an optional sparkline into an
NSImageassigned to the button. This avoids subview layout, is pixel-precise, and adapts correctly to light and dark. - Width stability is a hard rule. Each component reserves a fixed text width from a
worst-case template (
MenuBarComponent.template(for:)) and right-aligns monospaced digits inside it. An item must never change width because a value gained a digit. Digits are monospaced but unit letters are not, so a template is sized for the widest letter a formatter can emit —MenuBarTemplateTestschecks every component against its real formatter output rather than leaving the rule to a draw-time assertion. - Redraw is skipped when an item's render inputs are unchanged: handing AppKit an image invalidates the status item and round trips to the window server, which costs far more than drawing it did. Reducing work when nobody can see the menu bar happens a layer lower, in Energy Guard's sampling policy.
- ⌘-drag reordering is native;
NSStatusItem.autosaveNamepreserves position.
Mectrics is an LSUIElement agent: it launches with no Dock icon and no main window. It
becomes a regular application only while one of its own standard windows is on screen —
Settings, the Attention Log, Diagnostics, a metric detail, About, What's New, onboarding —
and drops back to .accessory when the last of them closes.
DockPresence owns that decision and every window
controller reports to it. The question cannot be answered from NSApplication.windows:
that collection also holds the window behind each status item, which is visible for the
whole life of the app, so "any visible window" is permanently true and the icon never goes
away once shown. Close ordering is a second trap — windowWillClose runs before the window
stops being visible — so the policy is applied one run-loop turn later, after AppKit has
finished. Because the decision is a plain object rather than an AppKit query, it is unit
tested, including a real window that is genuinely closed.
Settings drops its window on close rather than keeping it for the session. It is the app's heaviest surface — a SwiftUI window's working set is most of the footprint a menu bar agent is budgeted for, about 35 MB on the reference Mac — and the selected pane and frame are restored from preferences when it opens again.
What's New is keyed by version. ReleaseHighlights maps a marketing version to the two or
three things worth telling someone about, and the window shows the running build's entry;
before that it was one hardcoded list, so every upgrade was greeted with news from an older
release. A version with no entry presents nothing rather than something stale, and a test
fails if the current version has no notes.
The always-on-top floating panel that once existed was removed: it duplicated the menu bar without answering a question the menu bar could not, and it dragged along per-display placement, a global hotkey, and two layout modes.
The optional Compact Health item replaced it — a single stable-width status item that stays quiet and turns into a warning only when an alert routed to it activates. Real-time viewing therefore lives entirely in the menu bar and its popovers, and no second always-visible surface should be reintroduced.
The bundled mectrics CLI is a read-only automation interface for unattended machines,
not another dashboard. The app owns configuration. The CLI reads its enabled rules and can:
- wait for actual activation and recovery transitions, then print each new event as text or newline-delimited JSON without emitting an initial snapshot;
- list the effective rules in a stable, versioned JSON envelope;
- compare one fresh set of readings with the configured limits through
mectrics checkand return a script-friendly exit code; - capture every available module once through
mectrics snapshot, including the full detail dictionary in its versioned JSON output.
The one-shot check deliberately reports limitCrossed, not active: it does not wait for
a rule's sustained duration. Exit code 0 means healthy, 1 means at least one current
limit is crossed, and 2 means unconfigured or indeterminate because a reading is
unavailable. A known crossed limit takes priority over an unavailable reading. Both the app
and CLI use the same UI-independent alert models and evaluators from MetricsKit.
Process and health failures occupy separate exit-code namespaces. Invalid syntax uses
EX_USAGE (64), an internal serialization failure uses EX_SOFTWARE (70), and corrupt
saved rules use EX_CONFIG (78). The published health meanings of 0, 1, and 2 do not
change. Normal machine-readable data is written only to standard output; startup and
diagnostic messages use standard error.
The default JSON watch stream remains version 1 activation and recovery events. Opting into
--heartbeat switches to a tagged stream with ready, heartbeat, alert, and status
records. Heartbeats report both process liveness and data freshness. The watch consumes full
sampling-cycle reports: a failed provider never advances an alert from a cached reading, three
consecutive failures degrade coverage, and a reading older than the shared stale interval is
reported as stale. Coverage recovery is also emitted. Provider-backed and native conditions
are scheduled separately, so thermal pressure is read directly from ProcessInfo without
constructing or sampling a CPU provider.
The CLI constructs providers lazily from the enabled rules instead of initializing every hardware source and filtering afterward. Long-running watch sampling follows the current AC or battery interval and notices power-source changes. A watch session intentionally freezes its rule configuration at startup; changing rules in the app requires restarting the session. This keeps incident state transitions deterministic and is stated in the public help.
The executable remains inside the signed app bundle. The optional Install CLI… action
creates /usr/local/bin/mectrics as a symbolic link to that executable, so app updates also
update the command. It downloads no second binary and installs no background service. The
installer refuses to overwrite an unrelated command at that path, and app removal also
removes a link that belongs to Mectrics.
The internal metricskit-demo executable is separate. It renders every provider in a live
terminal view for development and hardware validation, is available only through SwiftPM,
and is not embedded in Mectrics.app.
TimelineProvider reads the last snapshot from the App Group container. The main app
periodically writes that snapshot plus a short history and calls
WidgetCenter.shared.reloadTimelines. The system throttles widget refreshes to minutes, so
the widget is positioned as "at a glance" while the menu bar carries the real-time job.
- Adaptive sampling — faster on AC, slower on battery, paused when the work is not visible.
- Energy Guard moves the engine between normal, reduced, and protected modes based on power source, Low Power Mode, thermal pressure, and whether anyone can currently see the menu bar — a sleeping display, a locked screen, and a switched-away session all count as asleep.
- Cost classes are the scheduler's dial:
.lightruns on every base cycle,.medium(battery, disk) and.heavy(SMC, GPU, sensors) are thinned by the intervals inSamplingRuntimePolicy, which Energy Guard widens as conditions tighten. Battery and disk are read every second base cycle even in normal mode: both move on the scale of minutes and each costs an IOKit round trip. - What is on screen decides which providers run at all, not only how often. The SMC is
sampled only where a temperature is actually shown — a
.temperaturemenu bar component, an open popover or detail window for CPU/Memory/GPU, the menu bar builder, or a rule that watches the CPU temperature directly. - The hot path is allocation-free: the ring buffer is pre-allocated.
- Providers copy the single IORegistry property they need rather than a whole property dictionary, and anything that reaches a system daemon (reclaimable disk space) runs on its own slow cadence.
- Targets: under 60 MB memory, low and steady CPU, "Energy Impact: Low" in Activity
Monitor. Memory means
phys_footprint— what Activity Monitor's "Memory" column reports — and notps rss, which counts shared framework pages every SwiftUI app maps and reads about three times higher. A Release build with the default four menu bar items measures a 27 MBphys_footprintp95 over a 31-minute run; the same build with the Settings window left open measures 62 MB, because a SwiftUI window's working set is most of that figure. The 60 MB budget describes the menu bar's steady state, which is what the app spends its life in. - Local points-of-interest signposts cover provider discovery, menu bar readiness, engine start, the first live sample, and popover presentation. They are visible to Instruments but are neither persisted nor transmitted.
scripts/performance/measure.shlaunches an isolated Release app or attaches to an explicit PID, records time-series CPU,phys_footprint, and connections, and evaluates p50/p95 plus long-run memory slope.measure-cli.shbenchmarks the real embedded CLI and can compare its p95 with an accepted local baseline. Results stay in ignored build output.- Baselines run without Instruments or
powermetrics; those tools are attached only to diagnose a failed budget because observation has its own cost. - A gate covers two workloads, because they fail for different reasons: the menu bar on its
own, and Settings deliberately open.
--profiledeclares the rules and layout under measurement,--settings <pane>sends the app amectrics://route so the window that is being measured is the one that was asked for.
Three things dominate, and none of them is arithmetic on a sample:
- Handing AppKit a new status item image. Every assignment is a window-server round
trip: the status item's scene settings are updated inside a Core Animation commit, and
AppKit then redraws the item's replicant snapshot by caching the button view into a
bitmap. An item whose render inputs are unchanged costs nothing, which is why
MetricStatusItemcompares them first — a menu bar of items that never change measures at 0% CPU. The price is per changed item per cycle, so the honest way to reduce it is to change fewer things, not to sample less often. - Rebuilding the menu bar.
MenuBarController.rebuild()destroys and re-creates everyNSStatusItem, and each one is a window the server has to register. This belongs to a change in which items exist, never to a change in their values, so component availability only grows within a session (see AGENTS.md §5). - Re-rendering a Settings pane on every sample. SwiftUI re-evaluating a pane rebuilds the tooltips and hover regions inside it; AppKit responds to a tracking-area change by re-resolving the pointer for the window, and on macOS 27 that re-resolution regenerates the accessibility cursor image — a Gaussian-blurred draw — each time. Left running, the pane's cost climbs rather than staying flat. The structural answer is the one already used in the menu bar: values live in small leaf views that reserve a fixed width, so a new reading repaints a caption instead of re-laying out and re-tracking the window.
The engine's own contribution is small by comparison. A Release app with ten enabled alert rules and no menu bar items at all measures well under 1% CPU, so a sampling cadence is worth tuning for energy — battery and disk are read every second base cycle because their readings move on the scale of minutes — but it is not where a CPU budget is won or lost.
Measured on the reference Mac (M2 Air, macOS 27 beta, AC, ten enabled rules, Release build with no debugger), the shape of the cost is:
| Configuration | CPU median |
|---|---|
| Ten rules, no menu bar items | 0.4% |
| One item whose value never changes | 0.0% |
| One item redrawing every second | 1.6% |
| Four items redrawing every second | 2.6% |
The first redraw of a cycle carries most of the price; further items are comparatively cheap. That is the shape of a fixed per-cycle window-server cost, not of arithmetic, and it is why the budget is defended by not redrawing rather than by sampling less.
An unresolved observation belongs here rather than in a release note: on macOS 27 beta this app is bimodal. Long runs sit at roughly 3.5% and then spend 20–60 seconds at a noticeably higher level before returning, and one 31-minute run entered a ~55% state and stayed there with memory flat. Pointer position, the measurement tools themselves, and a stray window have each been ruled out by experiment. It has not been reproduced in runs shorter than about twenty minutes, and it is not understood.
The gate is a gate, so what it currently reports is recorded rather than remembered. Both
runs below are 31 measured minutes after a five-minute warm-up, five-second sampling, on
AC power, on the reference Mac, with scripts/performance/profiles/alerts-ac.json — ten
enabled rules over the four menu bar items a clean install ships with.
| Gate | Budget | Menu bar only | Settings open |
|---|---|---|---|
| CPU median | ≤ 3% | 3.72% ✗ | 4.31% ✗ |
| CPU p95 | ≤ 5% | 8.22% ✗ | 8.63% ✗ |
| Sustained above 10% | none | 0 s ✓ | 5 s ✗ |
phys_footprint p95 |
≤ 60 MB | 27.0 MB ✓ | 62.1 MB ✗ |
| Memory slope | ≤ 1 MB/h | +4.65 ✗ | +2.86 ✗ |
| Idle connections | 0 | 0 ✓ | 0 ✓ |
These budgets are targets this project set for itself, deliberately tighter than "you would never notice"; missing the CPU one by well under a percentage point is a thing to keep working on, not an alarm. What the two columns actually say is more useful than the pass marks. The Settings column moved from a 74.9% median, 1422 seconds above 10%, and a 122 MB footprint growing at 21 MB/hour, so the pathology described above is gone. The menu bar column did not move: 3.72% against 3.72% on the same machine before the change. Sampling cadence and provider gating were not where that cost lived — the remaining distance is the per-redraw window-server price plus the bimodality noted above, and that is where the next attempt should look.
Public performance claims are deliberately narrower than the raw tooling. The 27 MB figure is a reference Release measurement, not a promise that every hardware and module combination will produce the same number. CPU is reported as a post-warm-up distribution and a sustained-spike duration because a launch or popover sample can briefly be high; macOS counts 100% process CPU as one fully occupied logical core. A run shorter than 30 measured minutes does not qualify the memory-growth gate and cannot be described as a soak result.
- Zero telemetry. The only permitted network call is an explicit update check.
- Release builds use Hardened Runtime, a Developer ID signature, notarization, and stapling.
- The main app is not sandboxed — IOKit metric access requires it. The widget extension is sandboxed.
- Releases are produced by
../scripts/release.sh, which archives, signs, packages a DMG, notarizes, and staples in one pass. - Private distribution-equivalent candidates use
../scripts/release-candidate.sh; they use a separate, versioned output directory and deliberately leave the appcast unchanged. - See
../PRIVACY.mdfor the privacy statement.
MetricsKitTestscovers computation correctness (Δbytes/Δt, CPU percentages), alert state transitions and JSON contracts, and store behaviour including concurrent reads and writes.MectricsCLITestscovers typed parsing, public exit codes, golden JSON fixtures, provider-failure freshness, stdout/stderr separation, and actual executable processes with isolated preferences.MectricsTestscovers app-layer logic: alert rules, Energy Guard, the Attention Log, diagnostics export redaction, menu bar layout presets, and URL routing.- XCTest microbenchmarks cover the ring-buffer hot path, menu bar formatting, and Energy Guard decisions. Whole-process release gates remain external so the test runner and debugger do not contaminate CPU, memory, or wakeup measurements.
- Hardware coverage is inherently manual: fanless machines, external displays, low battery, and machines whose SMC key set differs.
mectrics/
├── project.yml # XcodeGen definition — the source of the Xcode project
├── AGENTS.md # conventions (source of truth for contributors)
├── docs/ # this document, its assets, and the folder index
├── Mectrics/ # menu bar app (SwiftUI + AppKit)
│ ├── App/ # AppDelegate, AppModel, login item, widget snapshots
│ ├── MenuBar/ # NSStatusItem controllers + live sparkline drawing
│ ├── UI/ # popover, sparkline, formatting, themes, localization
│ ├── Onboarding/ # three-step first-launch flow
│ ├── Alerts/ # alert rules, threshold + system condition monitors
│ ├── Attention/ # local Attention Log store and window
│ ├── Energy/ # Energy Guard sampling policy
│ ├── Diagnostics/ # local-only system summary and export
│ ├── Release/ # About, What's New, update status (Sparkle)
│ ├── Settings/ # General / Menu Bar builder / Alerts
│ └── Resources/ # Localizable.xcstrings, assets, privacy manifest
├── MectricsShared/ # types shared between app and widget
├── MectricsWidget/ # small/medium/large WidgetKit overview
├── MectricsTests/ # app-layer unit tests
├── Packages/MetricsKit/ # the metric engine (SwiftPM)
│ ├── Sources/MetricsKit/ # providers, scheduler, store, alerts, engine, sharing
│ ├── Sources/MectricsCLICore/ # testable command parsing, sampling, output contracts
│ ├── Sources/MectricsCLI/ # read-only user automation interface
│ ├── Sources/MetricsKitDemo/ # internal live provider readout
│ └── Tests/ # engine and CLI contract suites
└── scripts/
├── release.sh # archive → sign → DMG → notarize → staple
├── release-candidate.sh # private notarized candidate, no appcast mutation
├── performance/ # Release process and CLI measurement gates
├── uninstall.sh # manual removal for a Mectrics that will not open
└── generate-banner.py # regenerates the README banner pair