-
Notifications
You must be signed in to change notification settings - Fork 56
feat(swift-sdk): in-app log export for beta diagnostics #4131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,22 +23,62 @@ public enum LoggingPreset: String { | |
| public enum LoggingPreferences { | ||
| private static let defaultsKey = "SwiftSDKLogLevel" | ||
|
|
||
| /// Sessions beyond this count are deleted at startup, oldest | ||
| /// first. File logging runs on devices too (beta diagnostics), | ||
| /// so growth must stay bounded without anyone thinking about it. | ||
| private static let maxRetainedSessions = 20 | ||
|
|
||
| /// Total-bytes quota across retained sessions. The Rust side | ||
| /// appends without rotation, so individual sessions can be | ||
| /// arbitrarily large — a count cap alone doesn't bound disk | ||
| /// use. Sessions are kept newest-first until the quota is hit; | ||
| /// the current session is always kept (its in-flight growth | ||
| /// can only be bounded by rotation on the Rust side). | ||
| private static let maxRetainedBytes: UInt64 = 100 * 1024 * 1024 | ||
|
|
||
| /// Root under which each launch creates one timestamped session | ||
| /// directory of per-crate `run.log` files. Exposed so diagnostics | ||
| /// features (log export) can enumerate sessions without | ||
| /// re-deriving the layout. | ||
| public static var logsRootDirectory: URL? { | ||
| FileManager.default | ||
| .urls(for: .libraryDirectory, in: .userDomainMask).first? | ||
| .appendingPathComponent("Logs", isDirectory: true) | ||
| .appendingPathComponent("SwiftDashSDK", isDirectory: true) | ||
| } | ||
|
|
||
| /// Session directory of the current launch, set once `configure()` | ||
| /// installs the file subscriber. `nil` when file logging is off | ||
| /// (subscriber already installed, or the path wasn't writable). | ||
| @MainActor | ||
| public private(set) static var currentSessionDirectory: URL? | ||
|
|
||
| /// The tracing subscriber is process-global and first-init-wins, | ||
| /// so the install must run at most once per process. Without | ||
| /// this guard, every `configure()` call after the first (e.g. | ||
| /// a bootstrap retry) would make the Rust initializer lay out a | ||
| /// fresh session directory of empty log files before `try_init` | ||
| /// discovers the existing subscriber and bails — leaving decoy | ||
| /// "newest" sessions that logging never writes to. | ||
| @MainActor | ||
| private static var didInstallLogging = false | ||
|
|
||
| @discardableResult | ||
| @MainActor | ||
| public static func configure() -> LoggingPreset { | ||
| let preset = loadPreset() | ||
| let enableSwiftVerbose: Bool | ||
|
|
||
| // File logging is gated to the iOS Simulator | ||
| #if targetEnvironment(simulator) | ||
| if let sessionRoot = launchLogPaths(), | ||
| SDK.enableFileLogging(level: .info, sessionRoot: sessionRoot) { | ||
| } else { | ||
| SDK.enableLogging(level: .info) | ||
| if !didInstallLogging { | ||
| didInstallLogging = true | ||
| if let sessionRoot = launchLogPaths(), | ||
| SDK.enableFileLogging(level: .info, sessionRoot: sessionRoot.path) { | ||
| currentSessionDirectory = sessionRoot | ||
| pruneOldSessions(keeping: sessionRoot) | ||
|
Comment on lines
+56
to
+77
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Cover the logging lifecycle fixes with regression tests [prior id=c0db94d40ec7] source: ['codex'] |
||
| } else { | ||
| SDK.enableLogging(level: .info) | ||
| } | ||
| } | ||
| #else | ||
| SDK.enableLogging(level: .info) | ||
| #endif | ||
|
|
||
| switch preset { | ||
| case .high: | ||
|
|
@@ -54,24 +94,79 @@ public enum LoggingPreferences { | |
| return preset | ||
| } | ||
|
|
||
| private static func launchLogPaths() -> String? { | ||
| guard | ||
| let libraryURL = FileManager.default | ||
| .urls(for: .libraryDirectory, in: .userDomainMask).first | ||
| else { | ||
| return nil | ||
| } | ||
| private static func launchLogPaths() -> URL? { | ||
| guard let root = logsRootDirectory else { return nil } | ||
|
|
||
| let formatter = DateFormatter() | ||
| formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| formatter.timeZone = TimeZone(secondsFromGMT: 0) | ||
| formatter.dateFormat = "yyyy-MM-dd'T'HH-mm-ss'Z'" | ||
|
|
||
| return libraryURL | ||
| .appendingPathComponent("Logs", isDirectory: true) | ||
| .appendingPathComponent("SwiftDashSDK", isDirectory: true) | ||
| return root | ||
| .appendingPathComponent(formatter.string(from: Date()), isDirectory: true) | ||
| .path | ||
| } | ||
|
|
||
| /// Delete session directories past either retention bound — | ||
| /// `maxRetainedSessions` count or `maxRetainedBytes` total — | ||
| /// walking newest-first so what survives is always the most | ||
| /// recent history. The current session is always kept | ||
| /// regardless of where its stamp sorts or how big it is. | ||
| /// Runs detached — deleting multi-megabyte directories has no | ||
| /// business on the main actor during launch. | ||
| private static func pruneOldSessions(keeping current: URL) { | ||
| guard let root = logsRootDirectory else { return } | ||
| Task.detached(priority: .utility) { | ||
| let fm = FileManager.default | ||
| guard let entries = try? fm.contentsOfDirectory( | ||
| at: root, | ||
| includingPropertiesForKeys: [.isDirectoryKey], | ||
| options: [.skipsHiddenFiles] | ||
| ) else { return } | ||
|
|
||
| // Session stamps are fixed-width UTC ISO timestamps, so | ||
| // lexicographic order is chronological order. | ||
| let sessions = entries | ||
| .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } | ||
| .sorted { $0.lastPathComponent > $1.lastPathComponent } | ||
|
|
||
| let currentName = current.lastPathComponent | ||
| var keptCount = 0 | ||
| var keptBytes: UInt64 = 0 | ||
| for session in sessions { | ||
| if session.lastPathComponent == currentName { | ||
| // Counted against the byte quota so a huge | ||
| // just-finished-syncing session pushes old | ||
| // history out, but never deleted itself. | ||
| keptCount += 1 | ||
| keptBytes += directoryBytes(of: session) | ||
| continue | ||
| } | ||
| let bytes = directoryBytes(of: session) | ||
| if keptCount >= maxRetainedSessions | ||
| || keptBytes + bytes > maxRetainedBytes { | ||
| try? fm.removeItem(at: session) | ||
|
Comment on lines
+136
to
+147
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: The current session can still grow without a byte bound [prior id=cec1a78fbe14] The 100 MB quota runs only during startup pruning, which unconditionally retains the current directory even when it exceeds the limit. The Rust logger opens seven files with append mode and installs those writers for the process lifetime ( source: ['codex'] |
||
| } else { | ||
| keptCount += 1 | ||
| keptBytes += bytes | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static func directoryBytes(of directory: URL) -> UInt64 { | ||
| guard let enumerator = FileManager.default.enumerator( | ||
| at: directory, | ||
| includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileSizeKey] | ||
| ) else { return 0 } | ||
|
|
||
| var total: UInt64 = 0 | ||
| for case let file as URL in enumerator { | ||
| let values = try? file.resourceValues( | ||
| forKeys: [.totalFileAllocatedSizeKey, .fileSizeKey] | ||
| ) | ||
| total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0) | ||
| } | ||
| return total | ||
| } | ||
|
|
||
| public static var preset: LoggingPreset { loadPreset() } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.