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: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ 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.
rotating, human-readable CSV — one file per run, 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
Expand Down
15 changes: 8 additions & 7 deletions Sources/MonitorCore/LogRetention.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,17 @@ public enum LogRetention: String, CaseIterable, Sendable {
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.
/// The day a file covers, read back from its name. The date and time are
/// the last components of the name, so a hostname that itself contains
/// dashes or dots cannot confuse the parse. The time is ignored: the period
/// is the start of the day, so retention still deletes whole days.
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
formatter.dateFormat = "yyyy_MM_dd_HH_mm_ss"
guard base.count >= 19 else { return nil }
guard let date = formatter.date(from: String(base.suffix(19))) else { return nil }
return period(for: date.timeIntervalSince1970)
}
}
20 changes: 10 additions & 10 deletions Sources/MonitorLog/CSVLogSink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import MonitorCore

/// Writes sampled batches to rotating CSV files.
///
/// One file per day, named `sensors.<host>.<date>.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.
/// One file per run, named `sensors.<host>.<date>_<time>.csv` so files from
/// several machines sharing a directory do not clobber, and a restart does not
/// append to the previous run's file. The header is written when a file is
/// first created.
///
/// 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
Expand Down Expand Up @@ -40,7 +40,7 @@ public actor CSVLogSink: SampleSink {
let period = LogRetention.period(for: batch.timestamp)
if current?.period != period {
closeCurrent()
open(period: period)
open(period: period, at: batch.timestamp)
}
guard let handle = current?.handle else { return }
let values = Dictionary(
Expand All @@ -66,8 +66,8 @@ public actor CSVLogSink: SampleSink {

// MARK: - Files

private func open(period: TimeInterval) {
let url = directory.appendingPathComponent(filename(for: period))
private func open(period: TimeInterval, at timestamp: TimeInterval) {
let url = directory.appendingPathComponent(filename(at: timestamp))
let isNew = !FileManager.default.fileExists(atPath: url.path)
if isNew {
FileManager.default.createFile(atPath: url.path, contents: nil)
Expand All @@ -86,11 +86,11 @@ public actor CSVLogSink: SampleSink {
current = nil
}

private func filename(for period: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: period)
private func filename(at timestamp: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: timestamp)
let formatter = DateFormatter()
formatter.timeZone = .current
formatter.dateFormat = "yyyy_MM_dd"
formatter.dateFormat = "yyyy_MM_dd_HH_mm_ss"
return "sensors.\(Self.sanitized(hostname)).\(formatter.string(from: date)).csv"
}

Expand Down
9 changes: 5 additions & 4 deletions Sources/monitord/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ OPTIONS
--interval <sec> sampling interval (default 1.0)

NOTES
Files are named sensors.<host>.<date>.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.
Files are named sensors.<host>.<date>_<time>.csv, one per run, so files from
several machines sharing a directory do not clobber, and a restart does not
append to the previous run's file. 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())
Expand Down
2 changes: 1 addition & 1 deletion Tests/MonitorCoreTests/LogRetentionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct LogRetentionTests {
month: 9,
day: 4
)))
let parsed = LogRetention.period(from: "sensors.my-host.2026_09_04.csv")
let parsed = LogRetention.period(from: "sensors.my-host.2026_09_04_12_34_56.csv")
#expect(parsed == day.timeIntervalSince1970)
}
}
24 changes: 24 additions & 0 deletions Tests/MonitorLogTests/CSVLogSinkTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,30 @@ struct CSVLogSinkTests {
#expect(files[0].lastPathComponent.hasPrefix("sensors.macbook-pro_local."))
}

@Test func filenameIncludesTimeOfDay() 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
)
let formatter = DateFormatter()
formatter.timeZone = .current
formatter.dateFormat = "yyyy_MM_dd_HH_mm_ss"
let expected = "sensors.myhost.\(formatter.string(from: Date(timeIntervalSince1970: t))).csv"
#expect(files[0].lastPathComponent == expected)
}

@Test func rollsOverOnCadenceBoundary() async throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
Expand Down
Loading