Files
james.maes 5bd90762a4 fix(01-WR-08): detect clock-skew rotation + on-demand retention GC
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.
2026-04-23 16:26:26 -05:00

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
}()
}