diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 34111cc..66929d8 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac6973d..fb1aabd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 7087a75..58a0fdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) @@ -77,8 +78,11 @@ 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 @@ -86,7 +90,7 @@ 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 diff --git a/Package.swift b/Package.swift index 28bb377..2f823d5 100644 --- a/Package.swift +++ b/Package.swift @@ -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"]), @@ -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. diff --git a/README.md b/README.md index 281e4c6..8776157 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/Scripts/make-app.sh b/Scripts/make-app.sh index 9746c26..3e09005 100755 --- a/Scripts/make-app.sh +++ b/Scripts/make-app.sh @@ -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" diff --git a/Scripts/notarize.sh b/Scripts/notarize.sh index 72db267..cbce932 100755 --- a/Scripts/notarize.sh +++ b/Scripts/notarize.sh @@ -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: @@ -24,14 +27,17 @@ set -euo pipefail -app="${1:-}" +package="${1:-}" zip="${2:-}" profile="${MONITOR_NOTARY_PROFILE:-}" -[ -d "$app" ] || { echo "usage: $0 " >&2; exit 1; } -[ -f "$zip" ] || { echo "usage: $0 " >&2; exit 1; } +[ -d "$package" ] || { echo "usage: $0 " >&2; exit 1; } +[ -f "$zip" ] || { echo "usage: $0 " >&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 @@ -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 diff --git a/Sources/MonitorCore/CSVLogFormat.swift b/Sources/MonitorCore/CSVLogFormat.swift new file mode 100644 index 0000000..6dea8ee --- /dev/null +++ b/Sources/MonitorCore/CSVLogFormat.swift @@ -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)) + } +} diff --git a/Sources/MonitorCore/LogRetention.swift b/Sources/MonitorCore/LogRetention.swift new file mode 100644 index 0000000..589539f --- /dev/null +++ b/Sources/MonitorCore/LogRetention.swift @@ -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 + } +} diff --git a/Sources/MonitorLog/CSVLogSink.swift b/Sources/MonitorLog/CSVLogSink.swift new file mode 100644 index 0000000..c978b39 --- /dev/null +++ b/Sources/MonitorLog/CSVLogSink.swift @@ -0,0 +1,126 @@ +import Foundation +import MonitorCore + +/// Writes sampled batches to rotating CSV files. +/// +/// One file per day, named `sensors...csv` so files from several +/// machines sharing a directory do not clobber. The header is written when a +/// file is first created; a file reopened after a restart appends without +/// repeating it. +/// +/// Retention deletes whole files whose period is older than the window. It runs +/// on a slow timer, not on every write, so a log that runs for days does not pay +/// for a directory listing every second. +public actor CSVLogSink: SampleSink { + private let directory: URL + private let hostname: String + private let retention: LogRetention + private let descriptors: [MetricDescriptor] + private var current: (period: TimeInterval, handle: FileHandle)? + private var lastSweep: TimeInterval = 0 + private let sweepInterval: TimeInterval = 60 + + public init( + directory: URL, + hostname: String, + retention: LogRetention, + descriptors: [MetricDescriptor] + ) throws { + self.directory = directory + self.hostname = hostname + self.retention = retention + self.descriptors = descriptors + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } + + public func receive(_ batch: SampleBatch) async { + let period = LogRetention.period(for: batch.timestamp) + if current?.period != period { + closeCurrent() + open(period: period) + } + guard let handle = current?.handle else { return } + let values = Dictionary( + batch.samples.map { ($0.metric, $0.value) }, + uniquingKeysWith: { _, latest in latest } + ) + let line = CSVLogFormat.row( + hostname: hostname, timestamp: batch.timestamp, + values: values, descriptors: descriptors + ) + try? handle.write(contentsOf: Data((line + "\n").utf8)) + if batch.timestamp - lastSweep >= sweepInterval { + lastSweep = batch.timestamp + sweep(now: batch.timestamp) + } + } + + /// Flush and close the open file. Call on shutdown so the last rows are not + /// left in the OS page cache. + public func close() { + closeCurrent() + } + + // MARK: - Files + + private func open(period: TimeInterval) { + let url = directory.appendingPathComponent(filename(for: period)) + let isNew = !FileManager.default.fileExists(atPath: url.path) + if isNew { + FileManager.default.createFile(atPath: url.path, contents: nil) + } + guard let handle = try? FileHandle(forWritingTo: url) else { return } + try? handle.seekToEnd() + if isNew { + let header = CSVLogFormat.header(hostname: hostname, descriptors: descriptors) + "\n" + try? handle.write(contentsOf: Data(header.utf8)) + } + current = (period, handle) + } + + private func closeCurrent() { + try? current?.handle.close() + current = nil + } + + private func filename(for period: TimeInterval) -> String { + let date = Date(timeIntervalSince1970: period) + let formatter = DateFormatter() + formatter.timeZone = .current + formatter.dateFormat = "yyyy_MM_dd" + return "sensors.\(Self.sanitized(hostname)).\(formatter.string(from: date)).csv" + } + + private func sweep(now: TimeInterval) { + guard let window = retention.seconds else { return } + let cutoff = now - window + let files = (try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + )) ?? [] + for file in files + where file.lastPathComponent.hasPrefix("sensors.\(Self.sanitized(hostname)).") + { + guard let period = LogRetention.period(from: file.lastPathComponent) + else { continue } + if period + 86400 <= cutoff { + try? FileManager.default.removeItem(at: file) + } + } + } + + /// A hostname becomes a safe filename fragment: lowercased, and any + /// character outside [a-z0-9-_] replaced with a single underscore, so + /// "MacBook-Pro.local" reads as "macbook-pro_local". + static func sanitized(_ hostname: String) -> String { + hostname + .lowercased() + .replacingOccurrences( + of: "[^a-z0-9\\-_]", + with: "_", + options: .regularExpression + ) + } +} diff --git a/Sources/monitord/main.swift b/Sources/monitord/main.swift new file mode 100644 index 0000000..dc27d60 --- /dev/null +++ b/Sources/monitord/main.swift @@ -0,0 +1,75 @@ +import Foundation +import MonitorCore +import MonitorLog +import MonitorSources + +// A headless daemon that logs every metric to rotating CSV files. +// +// The app's ring buffer is a ten-minute live view and dies with the window. +// This is the long-running counterpart: it samples on the same clock and writes +// human-readable CSV that other processes can read to correlate performance with +// temperature or throttling. Run it as a launchd LaunchAgent to log for days. + +let usage = """ +monitord — log system metrics to rotating CSV files. + +USAGE + monitord [options] + +OPTIONS + --dir directory for the CSV files (default: ~/Library/Logs/monitor) + --retention how long to keep files: 1h, 6h, 24h, 48h, 3d, 5d, 7d, 14d, 30d, forever + (default: 24h) + --interval sampling interval (default 1.0) + +NOTES + Files are named sensors...csv, one per day, so files from several + machines sharing a directory do not clobber. The hostname is lowercased and + any character outside [a-z0-9-_] becomes an underscore. Timestamps are ISO8601 + in UTC plus epoch millis. Temperatures appear in both degrees C and degrees F. +""" + +let arguments = Array(CommandLine.arguments.dropFirst()) + +func value(for flag: String) -> String? { + guard let index = arguments.firstIndex(of: flag), + index + 1 < arguments.count else { return nil } + return arguments[index + 1] +} + +let directory = value(for: "--dir").map { URL(fileURLWithPath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/monitor", isDirectory: true) +let retention = value(for: "--retention").flatMap(LogRetention.init(rawValue:)) ?? .oneDay +let interval = value(for: "--interval").flatMap(Double.init) ?? 1.0 + +let sources = SourceRegistry.makeAll() +let descriptors = sources.flatMap(\.descriptors) +let hostname = ProcessInfo.processInfo.hostName + +let sink = try CSVLogSink( + directory: directory, hostname: hostname, retention: retention, descriptors: descriptors +) +let sampler = Sampler(sources: sources, sinks: [sink], interval: interval) + +/// Stop cleanly on SIGINT/SIGTERM so the last rows are flushed to disk. +let signalSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) +signalSource.setEventHandler { + Task { + await sampler.stop() + await sink.close() + exit(0) + } +} + +signal(SIGINT, SIG_IGN) +signalSource.resume() + +Task { + await sampler.start() +} + +print( + "monitord: logging to \(directory.path) (retention \(retention.rawValue), interval \(Format.interval(interval)))" +) +RunLoop.main.run() diff --git a/Tests/MonitorCoreTests/CSVLogFormatTests.swift b/Tests/MonitorCoreTests/CSVLogFormatTests.swift new file mode 100644 index 0000000..07b756d --- /dev/null +++ b/Tests/MonitorCoreTests/CSVLogFormatTests.swift @@ -0,0 +1,53 @@ +import Foundation +@testable import MonitorCore +import Testing + +struct CSVLogFormatTests { + private let descriptors = [ + MetricDescriptor( + id: MetricID("sensor.temperature.cpu"), name: "CPU", + group: "Temperature", unit: .celsius + ), + MetricDescriptor( + id: MetricID("sensor.fan.1.speed"), name: "Fan 1", + group: "Fans", unit: .rpm + ), + ] + + @Test func headerListsHostnameTimeAndMetrics() { + let header = CSVLogFormat.header(hostname: "myhost", descriptors: descriptors) + #expect( + header + == "hostname,time_iso8601,time_epoch_ms,sensor.temperature.cpu (°C),sensor.temperature.cpu (°F),sensor.fan.1.speed (rpm)" + ) + } + + @Test func rowMapsValuesAndLeavesMissingEmpty() { + let timestamp = Date(timeIntervalSince1970: 1_750_000_000).timeIntervalSince1970 + let values: [MetricID: Double] = [MetricID("sensor.temperature.cpu"): 45.25] + let row = CSVLogFormat.row( + hostname: "myhost", timestamp: timestamp, values: values, descriptors: descriptors + ) + let fields = row.split(separator: ",", omittingEmptySubsequences: false) + #expect(fields[0] == "myhost") + #expect(fields[2] == "1750000000000") + #expect(fields[3] == "45.25") // °C + #expect(fields[4] == "113.45") // °F = 45.25 × 9/5 + 32 + #expect(fields[5] == "") // fan missed this tick + } + + @Test func rpmIsAWholeNumber() { + let timestamp = Date(timeIntervalSince1970: 1_750_000_000).timeIntervalSince1970 + let values: [MetricID: Double] = [MetricID("sensor.fan.1.speed"): 1234.7] + let row = CSVLogFormat.row( + hostname: "myhost", timestamp: timestamp, values: values, descriptors: descriptors + ) + let fields = row.split(separator: ",", omittingEmptySubsequences: false) + #expect(fields[3] == "") // temperature missed this tick + #expect(fields[5] == "1235") // rounded to a whole RPM + } + + @Test func iso8601IsUTC() { + #expect(CSVLogFormat.iso8601(0) == "1970-01-01T00:00:00Z") + } +} diff --git a/Tests/MonitorCoreTests/LogRetentionTests.swift b/Tests/MonitorCoreTests/LogRetentionTests.swift new file mode 100644 index 0000000..c53680b --- /dev/null +++ b/Tests/MonitorCoreTests/LogRetentionTests.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import MonitorCore +import Testing + +struct LogRetentionTests { + @Test func seconds() { + #expect(LogRetention.oneHour.seconds == 3600) + #expect(LogRetention.sixHours.seconds == 6 * 3600.0) + #expect(LogRetention.sevenDays.seconds == 7 * 86400.0) + #expect(LogRetention.forever.seconds == nil) + } + + @Test func periodIsMidnightLocal() throws { + let calendar = Calendar.current + let noon = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 9, + day: 4, + hour: 10 + ))) + let midnight = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 9, + day: 4 + ))) + let period = LogRetention.period(for: noon.timeIntervalSince1970) + #expect(period == midnight.timeIntervalSince1970) + } + + @Test func periodReadsBackFromFilename() throws { + let calendar = Calendar.current + let day = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 9, + day: 4 + ))) + let parsed = LogRetention.period(from: "sensors.my-host.2026_09_04.csv") + #expect(parsed == day.timeIntervalSince1970) + } +} diff --git a/Tests/MonitorLogTests/CSVLogSinkTests.swift b/Tests/MonitorLogTests/CSVLogSinkTests.swift new file mode 100644 index 0000000..5b03dba --- /dev/null +++ b/Tests/MonitorLogTests/CSVLogSinkTests.swift @@ -0,0 +1,147 @@ +import Foundation +import MonitorCore +@testable import MonitorLog +import Testing + +struct CSVLogSinkTests { + private let descriptors = [ + MetricDescriptor( + id: MetricID("sensor.temperature.cpu"), name: "CPU", + group: "Temperature", unit: .celsius + ), + ] + + private func tempDir() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("monitord-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test func writesHeaderAndRows() async throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let sink = try CSVLogSink( + directory: dir, hostname: "myhost", retention: .sevenDays, descriptors: descriptors + ) + let t = Date(timeIntervalSince1970: 1_750_000_000).timeIntervalSince1970 + await sink.receive(SampleBatch( + timestamp: t, + values: [MetricID("sensor.temperature.cpu"): 45.0] + )) + await sink.close() + + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ) + #expect(files.count == 1) + #expect(files[0].lastPathComponent.hasPrefix("sensors.myhost.")) + #expect(files[0].lastPathComponent.hasSuffix(".csv")) + let text = try String(contentsOf: files[0], encoding: .utf8) + #expect(text + .hasPrefix( + "hostname,time_iso8601,time_epoch_ms,sensor.temperature.cpu (°C),sensor.temperature.cpu (°F)\n" + )) + #expect(text.contains("myhost,")) + } + + @Test func eachRowEndsWithANewline() async throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let sink = try CSVLogSink( + directory: dir, hostname: "myhost", retention: .sevenDays, descriptors: descriptors + ) + let t = Date(timeIntervalSince1970: 1_750_000_000).timeIntervalSince1970 + await sink.receive(SampleBatch( + timestamp: t, + values: [MetricID("sensor.temperature.cpu"): 45.0] + )) + await sink.receive(SampleBatch( + timestamp: t + 1, + values: [MetricID("sensor.temperature.cpu"): 46.0] + )) + await sink.close() + + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ) + let text = try String(contentsOf: files[0], encoding: .utf8) + // Header plus two data rows, each on its own line. + #expect(text.split(separator: "\n").count == 3) + #expect(text.hasSuffix("\n")) + } + + @Test func hostnameIsSanitizedInFilename() async throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let sink = try CSVLogSink( + directory: dir, hostname: "MacBook-Pro.local", retention: .sevenDays, + descriptors: descriptors + ) + let t = Date(timeIntervalSince1970: 1_750_000_000).timeIntervalSince1970 + await sink.receive(SampleBatch( + timestamp: t, + values: [MetricID("sensor.temperature.cpu"): 45.0] + )) + await sink.close() + + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ) + #expect(files.count == 1) + #expect(files[0].lastPathComponent.hasPrefix("sensors.macbook-pro_local.")) + } + + @Test func rollsOverOnCadenceBoundary() async throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let sink = try CSVLogSink( + directory: dir, hostname: "myhost", retention: .sevenDays, descriptors: descriptors + ) + let day1 = Date(timeIntervalSince1970: 1_750_000_000).timeIntervalSince1970 + let day2 = day1 + 86400 + await sink.receive(SampleBatch( + timestamp: day1, + values: [MetricID("sensor.temperature.cpu"): 45.0] + )) + await sink.receive(SampleBatch( + timestamp: day2, + values: [MetricID("sensor.temperature.cpu"): 46.0] + )) + await sink.close() + + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ) + #expect(files.count == 2) + } + + @Test func retentionDeletesOldFiles() async throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let sink = try CSVLogSink( + directory: dir, hostname: "myhost", retention: .oneHour, descriptors: descriptors + ) + let t = Date(timeIntervalSince1970: 1_750_000_000).timeIntervalSince1970 + await sink.receive(SampleBatch( + timestamp: t, + values: [MetricID("sensor.temperature.cpu"): 45.0] + )) + // A day later: rolls to a new file, and the sweep deletes the first. + await sink.receive(SampleBatch( + timestamp: t + 86400, + values: [MetricID("sensor.temperature.cpu"): 46.0] + )) + await sink.close() + + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ) + #expect(files.count == 1) + } +} diff --git a/Tests/MonitorLogTests/HostnameSanitizationTests.swift b/Tests/MonitorLogTests/HostnameSanitizationTests.swift new file mode 100644 index 0000000..2ef964d --- /dev/null +++ b/Tests/MonitorLogTests/HostnameSanitizationTests.swift @@ -0,0 +1,66 @@ +import Foundation +@testable import MonitorLog +import Testing + +/// The hostname becomes a safe filename fragment: lowercased, and every +/// character outside [a-z0-9-_] replaced with a single underscore. Each +/// offending character becomes its own underscore — a run of them is not +/// collapsed — so "a b" reads as "a__b". +struct HostnameSanitizationTests { + @Test func sanitizesHostnames() { + let cases: [(input: String, expected: String)] = [ + // Already clean. + ("myhost", "myhost"), + ("my-host", "my-host"), + ("my_host", "my_host"), + ("macbook-pro-2026", "macbook-pro-2026"), + // Case is folded. + ("MyHost", "myhost"), + ("MacBook Pro 2026", "macbook_pro_2026"), + // Dots and spaces become underscores. + ("MacBook-Pro.local", "macbook-pro_local"), + ("a.b.c", "a_b_c"), + // The user's example. + ("Jimmy's Macbook Pro 2026", "jimmy_s_macbook_pro_2026"), + // Each offending character becomes its own underscore. + ("a b", "a__b"), + ("!!!", "___"), + // Leading and trailing spaces are not trimmed. + (" host ", "_host_"), + // Non-ASCII letters are not allowed. + ("café", "caf_"), + // A mix of allowed and replaced characters. + ("Jimmy's-Macbook_Pro", "jimmy_s-macbook_pro"), + // Empty stays empty. + ("", ""), + ] + for (input, expected) in cases { + #expect(CSVLogSink.sanitized(input) == expected, "sanitized(\(input))") + } + } + + @Test func resultIsAlwaysSafeForAFilename() { + let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789_-") + let inputs = [ + "Jimmy's Macbook Pro 2026", + "MacBook-Pro.local", + "a/b\\c:d*e?f\"gi|j", + "café 🍎", + " ", + ] + for input in inputs { + let result = CSVLogSink.sanitized(input) + #expect( + result.allSatisfy { allowed.contains($0) }, + "sanitized(\(input)) = \(result)" + ) + } + } + + @Test func cannotEscapeTheDirectory() { + // A hostname that tries to climb out of the log directory is flattened + // to a single safe token, so it can never name a file elsewhere. + #expect(CSVLogSink.sanitized("a/../b") == "a____b") + #expect(CSVLogSink.sanitized("..") == "__") + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index cf4120b..9ae2d60 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,6 +21,16 @@ open. Needs three things: Batched writes are mandatory, not optional. The endurance arithmetic is in `storage.md`. +**The CSV logger is done.** `monitord` is a headless daemon that 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. +It is the background sampler +half of this step, aimed at a different consumer: other processes that want to +correlate performance with temperature or throttling, rather than the app +reading its own history back. The SQLite store and the app's time-range picker +remain, for the app-side history. + ## A real `.app` bundle Mostly done. `Scripts/make-app.sh` wraps the release binary in `monitor.app`