@@ -23,22 +23,62 @@ public enum LoggingPreset: String {
2323public enum LoggingPreferences {
2424 private static let defaultsKey = " SwiftSDKLogLevel "
2525
26+ /// Sessions beyond this count are deleted at startup, oldest
27+ /// first. File logging runs on devices too (beta diagnostics),
28+ /// so growth must stay bounded without anyone thinking about it.
29+ private static let maxRetainedSessions = 20
30+
31+ /// Total-bytes quota across retained sessions. The Rust side
32+ /// appends without rotation, so individual sessions can be
33+ /// arbitrarily large — a count cap alone doesn't bound disk
34+ /// use. Sessions are kept newest-first until the quota is hit;
35+ /// the current session is always kept (its in-flight growth
36+ /// can only be bounded by rotation on the Rust side).
37+ private static let maxRetainedBytes : UInt64 = 100 * 1024 * 1024
38+
39+ /// Root under which each launch creates one timestamped session
40+ /// directory of per-crate `run.log` files. Exposed so diagnostics
41+ /// features (log export) can enumerate sessions without
42+ /// re-deriving the layout.
43+ public static var logsRootDirectory : URL ? {
44+ FileManager . default
45+ . urls ( for: . libraryDirectory, in: . userDomainMask) . first?
46+ . appendingPathComponent ( " Logs " , isDirectory: true )
47+ . appendingPathComponent ( " SwiftDashSDK " , isDirectory: true )
48+ }
49+
50+ /// Session directory of the current launch, set once `configure()`
51+ /// installs the file subscriber. `nil` when file logging is off
52+ /// (subscriber already installed, or the path wasn't writable).
53+ @MainActor
54+ public private( set) static var currentSessionDirectory : URL ?
55+
56+ /// The tracing subscriber is process-global and first-init-wins,
57+ /// so the install must run at most once per process. Without
58+ /// this guard, every `configure()` call after the first (e.g.
59+ /// a bootstrap retry) would make the Rust initializer lay out a
60+ /// fresh session directory of empty log files before `try_init`
61+ /// discovers the existing subscriber and bails — leaving decoy
62+ /// "newest" sessions that logging never writes to.
63+ @MainActor
64+ private static var didInstallLogging = false
65+
2666 @discardableResult
2767 @MainActor
2868 public static func configure( ) -> LoggingPreset {
2969 let preset = loadPreset ( )
3070 let enableSwiftVerbose : Bool
3171
32- // File logging is gated to the iOS Simulator
33- #if targetEnvironment(simulator)
34- if let sessionRoot = launchLogPaths ( ) ,
35- SDK . enableFileLogging ( level: . info, sessionRoot: sessionRoot) {
36- } else {
37- SDK . enableLogging ( level: . info)
72+ if !didInstallLogging {
73+ didInstallLogging = true
74+ if let sessionRoot = launchLogPaths ( ) ,
75+ SDK . enableFileLogging ( level: . info, sessionRoot: sessionRoot. path) {
76+ currentSessionDirectory = sessionRoot
77+ pruneOldSessions ( keeping: sessionRoot)
78+ } else {
79+ SDK . enableLogging ( level: . info)
80+ }
3881 }
39- #else
40- SDK . enableLogging ( level: . info)
41- #endif
4282
4383 switch preset {
4484 case . high:
@@ -54,24 +94,79 @@ public enum LoggingPreferences {
5494 return preset
5595 }
5696
57- private static func launchLogPaths( ) -> String ? {
58- guard
59- let libraryURL = FileManager . default
60- . urls ( for: . libraryDirectory, in: . userDomainMask) . first
61- else {
62- return nil
63- }
97+ private static func launchLogPaths( ) -> URL ? {
98+ guard let root = logsRootDirectory else { return nil }
6499
65100 let formatter = DateFormatter ( )
66101 formatter. locale = Locale ( identifier: " en_US_POSIX " )
67102 formatter. timeZone = TimeZone ( secondsFromGMT: 0 )
68103 formatter. dateFormat = " yyyy-MM-dd'T'HH-mm-ss'Z' "
69104
70- return libraryURL
71- . appendingPathComponent ( " Logs " , isDirectory: true )
72- . appendingPathComponent ( " SwiftDashSDK " , isDirectory: true )
105+ return root
73106 . appendingPathComponent ( formatter. string ( from: Date ( ) ) , isDirectory: true )
74- . path
107+ }
108+
109+ /// Delete session directories past either retention bound —
110+ /// `maxRetainedSessions` count or `maxRetainedBytes` total —
111+ /// walking newest-first so what survives is always the most
112+ /// recent history. The current session is always kept
113+ /// regardless of where its stamp sorts or how big it is.
114+ /// Runs detached — deleting multi-megabyte directories has no
115+ /// business on the main actor during launch.
116+ private static func pruneOldSessions( keeping current: URL ) {
117+ guard let root = logsRootDirectory else { return }
118+ Task . detached ( priority: . utility) {
119+ let fm = FileManager . default
120+ guard let entries = try ? fm. contentsOfDirectory (
121+ at: root,
122+ includingPropertiesForKeys: [ . isDirectoryKey] ,
123+ options: [ . skipsHiddenFiles]
124+ ) else { return }
125+
126+ // Session stamps are fixed-width UTC ISO timestamps, so
127+ // lexicographic order is chronological order.
128+ let sessions = entries
129+ . filter { ( try ? $0. resourceValues ( forKeys: [ . isDirectoryKey] ) ) ? . isDirectory == true }
130+ . sorted { $0. lastPathComponent > $1. lastPathComponent }
131+
132+ let currentName = current. lastPathComponent
133+ var keptCount = 0
134+ var keptBytes : UInt64 = 0
135+ for session in sessions {
136+ if session. lastPathComponent == currentName {
137+ // Counted against the byte quota so a huge
138+ // just-finished-syncing session pushes old
139+ // history out, but never deleted itself.
140+ keptCount += 1
141+ keptBytes += directoryBytes ( of: session)
142+ continue
143+ }
144+ let bytes = directoryBytes ( of: session)
145+ if keptCount >= maxRetainedSessions
146+ || keptBytes + bytes > maxRetainedBytes {
147+ try ? fm. removeItem ( at: session)
148+ } else {
149+ keptCount += 1
150+ keptBytes += bytes
151+ }
152+ }
153+ }
154+ }
155+
156+ private static func directoryBytes( of directory: URL ) -> UInt64 {
157+ guard let enumerator = FileManager . default. enumerator (
158+ at: directory,
159+ includingPropertiesForKeys: [ . totalFileAllocatedSizeKey, . fileSizeKey]
160+ ) else { return 0 }
161+
162+ var total : UInt64 = 0
163+ for case let file as URL in enumerator {
164+ let values = try ? file. resourceValues (
165+ forKeys: [ . totalFileAllocatedSizeKey, . fileSizeKey]
166+ )
167+ total += UInt64 ( values? . totalFileAllocatedSize ?? values? . fileSize ?? 0 )
168+ }
169+ return total
75170 }
76171
77172 public static var preset : LoggingPreset { loadPreset ( ) }
0 commit comments