Skip to content

Commit dc1d645

Browse files
feat(swift-sdk): in-app log export for beta diagnostics (#4131)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a843592 commit dc1d645

4 files changed

Lines changed: 587 additions & 20 deletions

File tree

packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,62 @@ public enum LoggingPreset: String {
2323
public 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

Comments
 (0)