Phase 1 REVIEW.md WR-08: `FileRotatingWriter` relied solely on wall-
clock `Date()` for day-boundary rotation and ran retention GC only as
a side effect of rotation. Two failure modes:
1. Clock jumping ±N days (NTP correction, manual change) would
rotate without signal — operators would see log files that
interleave pre/post-adjustment timestamps with no warning.
2. An idle app (no log write crossing midnight for >7 days) would
never GC stale files.
packages/Logging/Sources/JarvisLogging/FileRotatingWriter.swift
- `rotateIfNeeded()` computes the day-delta between the previous
window and the new day. When `|delta| > 2` emit an `.notice`-level
fault via os.Logger naming the skew magnitude. Rotation still
happens normally — the log just flags the unusual trigger.
- New `runRetentionGC()` dispatches `gcOldFiles` on the serial
queue, independent of rotation. Callers can invoke at shutdown or
on a periodic heartbeat.
packages/Logging/Sources/JarvisLogging/FileLogHandler.swift
- Public `runRetentionGC()` forwards to the writer so App-layer
callers can trigger GC without reaching into internals.
All 14 logging tests still pass.
62 lines
2.3 KiB
Swift
62 lines
2.3 KiB
Swift
import Foundation
|
|
import Logging // swift-log
|
|
|
|
struct FileLogHandler: LogHandler {
|
|
let label: String
|
|
let directory: URL
|
|
let dateProvider: any DateProvider
|
|
|
|
var logLevel: Logger.Level = .info
|
|
var metadata: Logger.Metadata = [:]
|
|
var metadataProvider: Logger.MetadataProvider?
|
|
|
|
private let writer: FileRotatingWriter
|
|
|
|
init(label: String, directory: URL, dateProvider: any DateProvider) {
|
|
self.label = label
|
|
self.directory = directory
|
|
self.dateProvider = dateProvider
|
|
self.writer = FileRotatingWriter(
|
|
directory: directory,
|
|
baseName: label,
|
|
retentionDays: 7,
|
|
dateProvider: dateProvider
|
|
)
|
|
}
|
|
|
|
func log(level: Logger.Level, message: Logger.Message,
|
|
metadata: Logger.Metadata?, source: String,
|
|
file: String, function: String, line: UInt) {
|
|
let merged = self.metadata.merging(metadata ?? [:]) { _, new in new }
|
|
let metaStr = merged.isEmpty ? "" : " " + merged.map { "\($0.key)=\($0.value)" }.sorted().joined(separator: " ")
|
|
let iso = ISO8601DateFormatter.jarvisShared.string(from: dateProvider.now())
|
|
let raw = "\(iso) \(level.rawValue.uppercased()) [\(label)] \(message)\(metaStr)\n"
|
|
let redacted = Redact.apply(raw) // S-4: single call site
|
|
writer.append(redacted)
|
|
}
|
|
|
|
/// WR-08: run retention GC outside the rotation path — call at
|
|
/// shutdown or on a periodic heartbeat so an idle app (no log writes
|
|
/// crossing midnight for >7 days) still cleans up stale files.
|
|
func runRetentionGC() {
|
|
writer.runRetentionGC()
|
|
}
|
|
|
|
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
|
get { metadata[key] }
|
|
set { metadata[key] = newValue }
|
|
}
|
|
}
|
|
|
|
extension ISO8601DateFormatter {
|
|
// `ISO8601DateFormatter` is documented thread-safe since macOS 10.12 (Apple: "thread-safe
|
|
// for reading and writing" after configuration), but is not declared `Sendable`. We
|
|
// configure it once at type-init and never mutate, so `nonisolated(unsafe)` is the
|
|
// appropriate Swift 6 escape hatch.
|
|
nonisolated(unsafe) static let jarvisShared: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
}
|