Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ jobs:
- name: Smoke test CLI help and version
run: |
set -euo pipefail
for binary in monitorctl monitord; do
for binary in monitorctl monitord monitor-exporter; do
swift run -c release "$binary" --help
swift run -c release "$binary" --version
done
# An unrecognised flag must be refused, not ignored. `!` because a
# non-zero exit is the pass condition.
! swift run -c release monitord --nonsense
! swift run -c release monitorctl list --nonsense
! swift run -c release monitor-exporter --nonsense

# monitord --help must not leave a CSV behind: writing one is exactly the
# symptom #48 reported.
Expand All @@ -74,6 +75,25 @@ jobs:
exit 1
fi

# Boot the exporter and scrape it once, the front-door check the argument
# tests cannot make: a served /metrics with the right shape. The runner is
# an M-series Mac with fans and a real GPU, so the full set is present —
# unlike a fanless machine, where the fan series is correctly absent.
- name: Smoke test the exporter endpoint
run: |
set -euo pipefail
swift build -c release --product monitor-exporter
bin="$(swift build -c release --product monitor-exporter --show-bin-path)/monitor-exporter"
"$bin" --bind-port 9650 &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT
# --retry-connrefused rather than a fixed sleep: connect as soon as the
# listener is up, and fail only if it never comes up.
body="$(curl --retry 20 --retry-connrefused --retry-delay 1 -fsS http://127.0.0.1:9650/metrics)"
echo "$body"
echo "$body" | grep -q monitor_exporter_build_info
echo "$body" | grep -q 'macos_smc_temperature_celsius{sensor="cpu"}'

lint:
name: Format check
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ jobs:
version="$(sed -n 's/.*static let string = "\(.*\)".*/\1/p' \
Sources/MonitorCore/Version.swift)"
name="monitor-${version}.zip"
# The package dir holds monitor.app/ and monitord side by side; ditto
# without --keepParent puts both at the top level of the zip.
# The package dir holds monitor.app/, monitord and monitor-exporter
# side by side; ditto without --keepParent puts them all at the top
# level of the zip.
ditto -c -k .build/package "$name"
echo "name=$name" >> "$GITHUB_OUTPUT"

Expand Down
14 changes: 12 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ swift run monitorctl read # read every metric once
swift run monitorctl watch --source disk --interval 0.5
swift run monitorctl watch --json --count 5 # machine-readable, bounded
swift run monitord --retention 7d --dir /tmp/logs # rotating CSV logger
swift run monitor-exporter # serve /metrics on 127.0.0.1:9650 for Prometheus
swift run monitorctl --help # generated from the declarations, never hand-written
swift run monitord --version # version and the commit it was built from
swiftformat Sources Tests Plugins --lint --cache ignore # CI lint gate
Expand Down Expand Up @@ -89,20 +90,29 @@ Sources/
linked into the app — see "Guardrails" below.
MonitorLog/ the rotating CSV logger: CSVLogSink. Written by monitord;
never linked into the app.
MonitorPrometheus/ the Prometheus exposition-format renderer (Exposition.swift)
and the sensor-metric mapping (PrometheusMapping.swift). Pure
MonitorCore, no macOS APIs, no dependency. Read by the exporter.
monitor/ the app target (@main SwiftUI App) and its AppDelegate
monitorctl/ headless CLI harness: Monitorctl.swift, an ArgumentParser
root with list/read/watch subcommands
monitord/ headless daemon that logs every metric to rotating CSV:
Monitord.swift, an ArgumentParser command
MonitorExporter/ headless daemon that serves the SMC and GPU sensors as
Prometheus metrics on GET /metrics (MonitorExporter.swift
command, MetricsHandler.swift, MetricsServer.swift). Binary
is monitor-exporter. Exports only the macOS gap node_exporter
cannot read; never links MonitorLog or MonitorStore.
Plugins/
StampCommit/ prebuild plugin: writes the commit into a Swift constant
before every build, so the title bar cannot go stale
Scripts/ make-app.sh, which builds monitor.app, make-icon.swift,
which draws its icon, and notarize.sh, which notarizes and
staples a Developer ID build
Tests/ MonitorCoreTests, MonitorSourcesTests, MonitorStoreTests,
MonitorLogTests, MonitorUITests, CommandLineTests (the two
CLIs' argument parsing — see "Making Changes")
MonitorLogTests, MonitorPrometheusTests, MonitorExporterTests,
MonitorUITests, CommandLineTests (the three headless tools'
argument parsing — see "Making Changes")
docs/ README.md is the index
.github/workflows/
ci.yml build, test, release build, CLI smoke tests (including
Expand Down
45 changes: 40 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ let package = Package(
.library(name: "MonitorStore", targets: ["MonitorStore"]),
.library(name: "MonitorUI", targets: ["MonitorUI"]),
.library(name: "MonitorLog", targets: ["MonitorLog"]),
.library(name: "MonitorPrometheus", targets: ["MonitorPrometheus"]),
.executable(name: "monitor", targets: ["monitor"]),
.executable(name: "monitorctl", targets: ["monitorctl"]),
.executable(name: "monitord", targets: ["monitord"]),
.executable(name: "monitor-exporter", targets: ["MonitorExporter"]),
],
dependencies: [
// The only third-party dependency, and it is Apple's. Both CLIs used to
Expand All @@ -61,6 +63,11 @@ let package = Package(
// The rotating CSV logger. `monitord` writes it; the app never links it,
// so the app still has no code path that reaches the filesystem.
.target(name: "MonitorLog", dependencies: ["MonitorCore"]),
// The Prometheus exposition-format renderer and the sensor-metric
// mapping. Pure MonitorCore, no macOS APIs and no new dependency, so
// the format is a golden-file test rather than a hand-rolled printer
// nobody checks. Read by monitor-exporter.
.target(name: "MonitorPrometheus", dependencies: ["MonitorCore"]),
// Note the absence of MonitorStore in the next three targets. That is
// the point, not an oversight.
.target(name: "MonitorUI", dependencies: ["MonitorCore", "MonitorSources"]),
Expand All @@ -70,31 +77,59 @@ let package = Package(
dependencies: [
"MonitorCore", "MonitorSources",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
]
),
.executableTarget(
name: "monitord",
dependencies: [
"MonitorLog", "MonitorSources",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
]
),
// The Prometheus exporter. Reads the gap sources node_exporter cannot
// read on macOS (SMC, GPU) at scrape time and serves them on
// GET /metrics. Links MonitorPrometheus for the format, never MonitorLog
// or MonitorStore — it writes no files.
.executableTarget(
name: "MonitorExporter",
dependencies: [
"MonitorPrometheus", "MonitorSources", "MonitorCore",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
.testTarget(name: "MonitorCoreTests", dependencies: ["MonitorCore"]),
.testTarget(
name: "MonitorSourcesTests",
dependencies: ["MonitorSources", "MonitorCore"]),
dependencies: ["MonitorSources", "MonitorCore"]
),
.testTarget(name: "MonitorStoreTests", dependencies: ["MonitorStore", "MonitorCore"]),
.testTarget(name: "MonitorLogTests", dependencies: ["MonitorLog", "MonitorCore"]),
// The renderer's format is golden-file tested; the mapping is tested
// against the real SMCSource/GPUSource MetricID constants, so a renamed
// id breaks the test rather than the exporter silently dropping a metric.
.testTarget(
name: "MonitorPrometheusTests",
dependencies: ["MonitorPrometheus", "MonitorCore", "MonitorSources"]
),
// The two CLIs' argument parsing. The bug that motivated it (#48) was
// invisible to every other suite: both binaries built, ran and sampled
// correctly, and only their front doors were wrong.
.testTarget(
name: "CommandLineTests",
dependencies: [
"monitorctl", "monitord", "MonitorCore",
"monitorctl", "monitord", "MonitorExporter", "MonitorCore",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
]
),
// AppModel decides what the panel draws and which sources are read on
// a given tick. Both are arithmetic, and both are wrong in ways that
// look like a rendering glitch, so they are worth testing directly.
.testTarget(name: "MonitorUITests", dependencies: ["MonitorUI", "MonitorCore"]),
// The handler (with a fake source, no hardware) and the server (routing
// pure, plus one real bound-port scrape).
.testTarget(
name: "MonitorExporterTests",
dependencies: ["MonitorExporter", "MonitorCore", "MonitorPrometheus"]
),
]
)
45 changes: 41 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Three executables, five libraries and a build plugin, in one SwiftPM package.
| `monitor` | The SwiftUI app. Realtime panel of gauges and charts, ten minutes of in-memory history. | yes, as `monitor.app` |
| `monitord` | Headless daemon. Samples every metric on one clock and writes rotating CSV. | yes, as a bare binary |
| `monitorctl` | Headless CLI. Lists, reads and watches the same metrics in a terminal. | no — a development tool |
| `monitor-exporter` | Headless daemon. Serves the SMC and GPU sensors as Prometheus metrics on `GET /metrics`. | yes, as a bare binary |

| Library | What it holds |
|---------|---------------|
Expand All @@ -38,6 +39,7 @@ Three executables, five libraries and a build plugin, in one SwiftPM package.
| `MonitorUI` | The dashboard: theme, gauges, chart cards, preferences, drag-to-reorder, `AppModel`. |
| `MonitorLog` | `CSVLogSink`, the rotating CSV writer. Used by `monitord`; never linked into the app. |
| `MonitorStore` | SQLite history and retention. Written and tested, deliberately **not** linked into any executable — see [docs/storage.md](docs/storage.md). |
| `MonitorPrometheus` | The Prometheus exposition-format renderer and the sensor-metric mapping. Pure `MonitorCore`; no macOS APIs, no dependency. Used by `monitor-exporter`. |

`Plugins/StampCommit` is a prebuild plugin that writes the current commit into a
Swift constant, so the app's title bar cannot claim a stale build.
Expand All @@ -46,8 +48,8 @@ Swift constant, so the app's title bar cannot claim a stale build.

Grab the latest `monitor-*.zip` from
[Releases](https://github.com/evanwtf/monitor/releases/latest), unzip it, and
drag `monitor.app` to Applications. The zip also contains `monitord`, so a
downloader runs `./monitord` with no toolchain installed.
drag `monitor.app` to Applications. The zip also contains `monitord` and
`monitor-exporter`, so a downloader runs either with no toolchain installed.

Releases are ad-hoc signed and not notarized unless the repository's
`SIGN_IDENTITY` and `NOTARY_PROFILE` variables are set, in which case the
Expand Down Expand Up @@ -120,8 +122,43 @@ appends to the previous run's file. Timestamps are ISO8601 in UTC plus epoch
millis, and temperatures appear in both °C and °F. Run it as a launchd
`LaunchAgent` to log for days.

Both CLIs support `--help` and `--version`, print usage for an unrecognised
flag, and exit non-zero rather than starting.
### `monitor-exporter` — serve metrics to Prometheus

```sh
swift run monitor-exporter # serve /metrics on 127.0.0.1:9650
swift run monitor-exporter --bind-address 0.0.0.0 # reachable from a remote Prometheus
./monitor-exporter --bind-port 9700 # from the release zip
curl -s localhost:9650/metrics
```

| Option | Meaning |
|--------|---------|
| `--bind-port <port>` | TCP port to listen on. Default `9650`. |
| `--bind-address <addr>` | Address to bind. Default `127.0.0.1`; `0.0.0.0` for a remote Prometheus. |

A long-running daemon that answers `GET /metrics` for a Prometheus server to
scrape. It reads the sensors *at scrape time*, so the scrape interval is the
sampling rate — there is nothing to configure. It exports only what
node_exporter cannot read on macOS: the SMC (temperature, fans, power) and the
GPU (utilization, VRAM). CPU, memory, disk and network are node_exporter's job,
so nothing is scraped twice. A sensor this machine does not have produces **no
series** rather than a zero — a fanless Mac has no `macos_smc_fan_rpm`. The
metric names:

| Metric | Labels |
|--------|--------|
| `macos_smc_temperature_celsius` | `sensor="cpu\|gpu\|storage\|battery\|enclosure\|ambient"` |
| `macos_smc_fan_rpm` | `fan="1\|2\|…"` |
| `macos_smc_power_watts` | `rail="input\|soc"` |
| `macos_gpu_utilization_ratio` | — |
| `macos_gpu_vram_used_bytes` | — |
| `monitor_exporter_build_info` | `version`, `commit` (value always `1`) |

Run it as a launchd `LaunchAgent`; [docs/exporter.md](docs/exporter.md) has the
plist and a Prometheus `scrape_configs` job.

All three headless tools support `--help` and `--version`, print usage for an
unrecognised flag, and exit non-zero rather than starting.

## What it measures

Expand Down
32 changes: 20 additions & 12 deletions Scripts/make-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -133,30 +133,38 @@ fi

echo "Built $app"

# The daemon ships alongside the app, so a release zip has both at the top
# level: monitor.app/ and monitord. Build it and stage the pair into
# .build/package/, which the CI packaging step zips.
# The headless binaries ship alongside the app, so a release zip has all three
# at the top level: monitor.app/, monitord and monitor-exporter. Build them and
# stage the set into .build/package/, which the CI packaging step zips.
echo "Building monitord…"
# --show-bin-path prints the path but does not build, so build first.
swift build -c release --product monitord
monitord="$(swift build -c release --product monitord --show-bin-path)/monitord"
[ -x "$monitord" ] || { echo "no binary at $monitord" >&2; exit 1; }

# The daemon must be signed too, or the notary service rejects the whole zip:
# it scans every binary in the archive, and an unsigned one is "Invalid".
if [ -n "$identity" ]; then
codesign --force --options runtime --timestamp --sign "$identity" "$monitord"
else
codesign --force --sign - --timestamp=none "$monitord" >/dev/null 2>&1 \
|| echo "warning: could not sign $monitord; it will still run" >&2
fi
echo "Building monitor-exporter…"
swift build -c release --product monitor-exporter
exporter="$(swift build -c release --product monitor-exporter --show-bin-path)/monitor-exporter"
[ -x "$exporter" ] || { echo "no binary at $exporter" >&2; exit 1; }

# Every binary in the archive must be signed, or the notary service rejects the
# whole zip: it scans them all, and an unsigned one is "Invalid".
for binary in "$monitord" "$exporter"; do
if [ -n "$identity" ]; then
codesign --force --options runtime --timestamp --sign "$identity" "$binary"
else
codesign --force --sign - --timestamp=none "$binary" >/dev/null 2>&1 \
|| echo "warning: could not sign $binary; it will still run" >&2
fi
done

package=".build/package"
rm -rf "$package"
mkdir -p "$package"
cp -R "$app" "$package/monitor.app"
cp "$monitord" "$package/monitord"
echo "Staged $package (monitor.app, monitord)"
cp "$exporter" "$package/monitor-exporter"
echo "Staged $package (monitor.app, monitord, monitor-exporter)"

if [ -n "$destination" ]; then
mkdir -p "$destination"
Expand Down
48 changes: 48 additions & 0 deletions Sources/MonitorExporter/MetricsHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation
import MonitorCore
import MonitorPrometheus

/// Reads the sensor sources once per scrape and renders the /metrics body.
///
/// Locked: a source such as SMCSource holds a single IOKit connection, and two
/// overlapping scrapes must not read it at the same time.
final class MetricsHandler: @unchecked Sendable {
private let sources: [any MetricSource]
private let lock = NSLock()

init(sources: [any MetricSource]) {
self.sources = sources
}

/// The full /metrics body for one scrape. A source that throws, or a metric
/// that does not map, contributes nothing — gap, never zero.
func exposition(now: TimeInterval = Date().timeIntervalSince1970) -> String {
lock.lock()
defer { lock.unlock() }

var entries: [(MappedMetric, Double)] = []
for source in sources {
guard let batch = try? source.read(at: now) else { continue }
for sample in batch.samples {
guard let mapped = PrometheusMapping.map(sample.metric) else { continue }
entries.append((mapped, sample.value))
}
}
var families = PrometheusFamilyBuilder.families(from: entries)
families.append(Self.buildInfo)
return renderExposition(families)
}

/// A constant series carrying the build, so a scrape says which binary answered.
static let buildInfo = PrometheusFamily(
name: "monitor_exporter_build_info",
help: "Build information; the value is always 1.",
samples: [PrometheusSample(
labels: [
PrometheusLabel(name: "version", value: MonitorVersion.string),
PrometheusLabel(name: "commit", value: BuildStamp.commit),
],
value: 1
)]
)
}
Loading
Loading