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
6 changes: 4 additions & 2 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ jobs:
version="$(sed -n 's/.*static let string = "\(.*\)".*/\1/p' \
Sources/MonitorCore/Version.swift)"
name="monitor-${version}.zip"
ditto -c -k --keepParent .build/monitor.app "$name"
# The package dir holds monitor.app/ and monitord side by side; ditto
# without --keepParent puts both at the top level of the zip.
ditto -c -k .build/package "$name"
echo "name=$name" >> "$GITHUB_OUTPUT"

# Between zipping and the checksum, because notarize.sh rebuilds the zip
Expand All @@ -78,7 +80,7 @@ jobs:
if: vars.NOTARY_PROFILE != ''
env:
MONITOR_NOTARY_PROFILE: ${{ vars.NOTARY_PROFILE }}
run: Scripts/notarize.sh .build/monitor.app "${{ steps.package.outputs.name }}"
run: Scripts/notarize.sh .build/package "${{ steps.package.outputs.name }}"

- name: Checksum
id: checksum
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ jobs:
cat > notes.md <<'NOTES'
Download the zip below, unzip it, and drag `monitor.app` to
Applications. macOS 14 or later, Apple silicon or Intel.

The zip also contains `monitord`, a headless daemon that logs every
metric to rotating CSV files. Run `./monitord` to log at 1s with 1d
retention, or pass `--retention` and `--interval` to change it.
NOTES

if [ "$NOTARIZED" != "true" ]; then
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ swift run monitorctl list # every source and the metrics it declares
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
swiftformat Sources Tests Plugins --lint --cache ignore # CI lint gate
Scripts/make-app.sh [dest] # wrap the release binary in monitor.app
Scripts/make-icon.swift out.icns # draw the app icon (make-app.sh calls this)
Expand Down Expand Up @@ -77,16 +78,19 @@ Sources/
LayoutPreferencesStore (layout, sampling, arrangement)
MonitorStore/ SQLite history and retention. Designed and tested but NOT
linked into the app — see "Guardrails" below.
MonitorLog/ the rotating CSV logger: CSVLogSink. Written by monitord;
never linked into the app.
monitor/ the app target (@main SwiftUI App) and its AppDelegate
monitorctl/ headless CLI harness
monitord/ headless daemon that logs every metric to rotating CSV
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,
MonitorUITests
MonitorLogTests, MonitorUITests
docs/ README.md is the index
.github/workflows/
ci.yml build, test, release build, CLI smoke test, lint
Expand Down
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ let package = Package(
.library(name: "MonitorSources", targets: ["MonitorSources"]),
.library(name: "MonitorStore", targets: ["MonitorStore"]),
.library(name: "MonitorUI", targets: ["MonitorUI"]),
.library(name: "MonitorLog", targets: ["MonitorLog"]),
.executable(name: "monitor", targets: ["monitor"]),
.executable(name: "monitorctl", targets: ["monitorctl"]),
.executable(name: "monitord", targets: ["monitord"]),
],
targets: [
.target(name: "MonitorCore", plugins: ["StampCommit"]),
Expand All @@ -47,16 +49,21 @@ let package = Package(
.plugin(name: "StampCommit", capability: .buildTool()),
.target(name: "MonitorSources", dependencies: ["MonitorCore"]),
.target(name: "MonitorStore", dependencies: ["MonitorCore"]),
// 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"]),
// Note the absence of MonitorStore in the next three targets. That is
// the point, not an oversight.
.target(name: "MonitorUI", dependencies: ["MonitorCore", "MonitorSources"]),
.executableTarget(name: "monitor", dependencies: ["MonitorUI"]),
.executableTarget(name: "monitorctl", dependencies: ["MonitorCore", "MonitorSources"]),
.executableTarget(name: "monitord", dependencies: ["MonitorLog", "MonitorSources"]),
.testTarget(name: "MonitorCoreTests", dependencies: ["MonitorCore"]),
.testTarget(
name: "MonitorSourcesTests",
dependencies: ["MonitorSources", "MonitorCore"]),
.testTarget(name: "MonitorStoreTests", dependencies: ["MonitorStore", "MonitorCore"]),
.testTarget(name: "MonitorLogTests", dependencies: ["MonitorLog", "MonitorCore"]),
// 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.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,18 @@ swift run monitorctl list # what can be measured
swift run monitorctl read # one reading of everything
swift run monitorctl watch --source disk --interval 0.5
swift run monitorctl watch --json | jq # machine-readable
swift run monitord --retention 7d --dir /tmp/logs # rotating CSV logger
```

`monitord` is the logger: it samples every metric on the same clock and writes
rotating, human-readable CSV — one file per day, hostname in the filename and as
a column, timestamps in ISO8601 and epoch millis, temperatures in both °C and
°F. Run it as a launchd `LaunchAgent` to log for days.

With no options it logs at 1s with 1d retention to `~/Library/Logs/monitor`.
The release zip ships a standalone `monitord` binary alongside `monitor.app`, so
a downloader runs `./monitord` — no `swift run` needed.

## What it measures

| Group | Metrics | Source |
Expand Down
14 changes: 14 additions & 0 deletions Scripts/make-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ 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.
echo "Building monitord…"
monitord="$(swift build -c release --product monitord --show-bin-path)/monitord"
[ -x "$monitord" ] || { echo "no binary at $monitord" >&2; exit 1; }

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)"

if [ -n "$destination" ]; then
mkdir -p "$destination"
rm -rf "${destination%/}/monitor.app"
Expand Down
16 changes: 11 additions & 5 deletions Scripts/notarize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
# Notarize a signed monitor.app and staple the ticket to it.
#
# Usage:
# Scripts/notarize.sh .build/monitor.app monitor-1.1.0.zip
# Scripts/notarize.sh .build/package monitor-1.1.0.zip
#
# The first argument is the package directory holding monitor.app/ and monitord
# side by side. The zip is rebuilt from it, so the daemon survives notarization.
#
# Needs a notarytool credential profile in the keychain, named by
# MONITOR_NOTARY_PROFILE. Create it once per machine:
Expand All @@ -24,14 +27,17 @@

set -euo pipefail

app="${1:-}"
package="${1:-}"
zip="${2:-}"
profile="${MONITOR_NOTARY_PROFILE:-}"

[ -d "$app" ] || { echo "usage: $0 <app> <zip>" >&2; exit 1; }
[ -f "$zip" ] || { echo "usage: $0 <app> <zip>" >&2; exit 1; }
[ -d "$package" ] || { echo "usage: $0 <package> <zip>" >&2; exit 1; }
[ -f "$zip" ] || { echo "usage: $0 <package> <zip>" >&2; exit 1; }
[ -n "$profile" ] || { echo "MONITOR_NOTARY_PROFILE is not set" >&2; exit 1; }

app="$package/monitor.app"
[ -d "$app" ] || { echo "no monitor.app in $package" >&2; exit 1; }

# A bundle signed ad-hoc is refused by the notary service with a message that
# does not say so. Catching it here costs one command and a minute of waiting.
# Two traps in one line, both of which reported a correctly signed bundle as
Expand Down Expand Up @@ -59,7 +65,7 @@ xcrun stapler validate "$app"

echo "Rebuilding ${zip} around the stapled bundle…"
rm -f "$zip"
ditto -c -k --keepParent "$app" "$zip"
ditto -c -k "$package" "$zip"

# Gatekeeper's own verdict, which is the question a downloader is really asking.
# It reads the staple rather than calling Apple, so this passes with the network
Expand Down
82 changes: 82 additions & 0 deletions Sources/MonitorCore/CSVLogFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Foundation

/// Turns a batch of samples into a wide CSV row for the rotating log.
///
/// Wide rather than long: one row per timestamp, one column per metric, so a
/// human can scan across a row and a tool can load it into a table. The header
/// is written once per file, from the descriptors, so a fanless Mac simply has
/// no fan column.
///
/// A temperature is written twice — in °C and in °F — because a log read by a
/// human or a tool on either side of the Atlantic should not make the reader
/// convert. Two time columns — ISO8601 in UTC and epoch millis — because a human
/// reads the first and a tool reads the second. A metric that produced no
/// sample this tick leaves an empty field, never a zero, so a gap does not read
/// as a cold die.
public enum CSVLogFormat {
/// One output column: a name and how to format a value for it.
struct Column {
let name: String
let format: (Double) -> String
}

public static func header(hostname _: String, descriptors: [MetricDescriptor]) -> String {
let names = descriptors.flatMap { columns(for: $0) }.map(\.name)
return CSVExport.row(["hostname", "time_iso8601", "time_epoch_ms"] + names)
}

public static func row(
hostname: String,
timestamp: TimeInterval,
values: [MetricID: Double],
descriptors: [MetricDescriptor]
) -> String {
let iso = iso8601(timestamp)
let epochMs = Int64((timestamp * 1000).rounded())
let fields = descriptors.flatMap { descriptor in
columns(for: descriptor).map { column in
values[descriptor.id].map(column.format) ?? ""
}
}
return CSVExport.row([hostname, iso, String(epochMs)] + fields)
}

/// A temperature becomes two columns, °C and °F; anything else is one.
static func columns(for descriptor: MetricDescriptor) -> [Column] {
if descriptor.unit == .celsius {
return [
Column(name: "\(descriptor.id.rawValue) (°C)") { number($0, decimals: 2) },
Column(name: "\(descriptor.id.rawValue) (°F)") { number(
$0 * 9 / 5 + 32,
decimals: 2
) },
]
}
return [
Column(name: "\(descriptor.id.rawValue) (\(Format.baseUnit(descriptor.unit)))") {
number($0, unit: descriptor.unit)
},
]
}

/// Two decimals for anything fractional; whole numbers for the units that
/// are counts. A log is read for trends, not for the fourth decimal.
static func number(_ value: Double, unit: MetricUnit) -> String {
switch unit {
case .rpm, .bytes, .count, .hertz: number(value, decimals: 0)
default: number(value, decimals: 2)
}
}

static func number(_ value: Double, decimals: Int) -> String {
guard value.isFinite else { return "" }
return String(format: "%.\(decimals)f", value)
}

static func iso8601(_ timestamp: TimeInterval) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.string(from: Date(timeIntervalSince1970: timestamp))
}
}
57 changes: 57 additions & 0 deletions Sources/MonitorCore/LogRetention.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

/// How long a CSV log is kept.
///
/// The log rolls once a day, at 00:00:00 local time, so a file's name reads as
/// the day it covers. Retention deletes whole files older than the window; a
/// sub-day window therefore keeps today's file, which can hold up to a day of
/// data — the window is a floor, not a promise at sub-day granularity.
public enum LogRetention: String, CaseIterable, Sendable {
case oneHour = "1h"
case sixHours = "6h"
case oneDay = "24h"
case twoDays = "48h"
case threeDays = "3d"
case fiveDays = "5d"
case sevenDays = "7d"
case fourteenDays = "14d"
case thirtyDays = "30d"
case forever

/// How long data is kept. Nil means "forever" — you own the disk.
public var seconds: TimeInterval? {
switch self {
case .oneHour: 3600
case .sixHours: 6 * 3600
case .oneDay: 24 * 3600
case .twoDays: 2 * 86400
case .threeDays: 3 * 86400
case .fiveDays: 5 * 86400
case .sevenDays: 7 * 86400
case .fourteenDays: 14 * 86400
case .thirtyDays: 30 * 86400
case .forever: nil
}
}

/// The start of the day a timestamp falls in, in local time.
public static func period(for timestamp: TimeInterval) -> TimeInterval {
let date = Date(timeIntervalSince1970: timestamp)
let calendar = Calendar.current
let components = calendar.dateComponents([.year, .month, .day], from: date)
return calendar.date(from: components)?.timeIntervalSince1970 ?? timestamp
}

/// The day a file covers, read back from its name. The date is the last
/// component of the name, so a hostname that itself contains dashes or dots
/// cannot confuse the parse.
public static func period(from filename: String) -> TimeInterval? {
let base = filename.hasSuffix(".csv") ? String(filename.dropLast(4)) : filename
let formatter = DateFormatter()
formatter.timeZone = .current
formatter.dateFormat = "yyyy_MM_dd"
guard base.count >= 10 else { return nil }
guard let date = formatter.date(from: String(base.suffix(10))) else { return nil }
return date.timeIntervalSince1970
}
}
Loading
Loading