Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 115 additions & 20 deletions packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
QuantumExplorer marked this conversation as resolved.

/// 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Cover the logging lifecycle fixes with regression tests

[prior id=c0db94d40ec7] LogExporterSelectionTests now covers authoritative selection, ordering, count limits, and export byte limits. However, the test tree still has no coverage for repeated configure() calls, preservation of currentSessionDirectory, or startup pruning across the session-count, retained-byte, and current-session rules at SDKLogger.swift:116-169. These paths implement the fixes for two prior blocking regressions and should have focused lifecycle and filesystem-policy tests.

source: ['codex']

} else {
SDK.enableLogging(level: .info)
}
}
#else
SDK.enableLogging(level: .info)
#endif

switch preset {
case .high:
Expand All @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 (rs-platform-wallet-ffi/src/logging.rs:39-60,142-151), with no rotation or live size enforcement. A long-running device session can therefore grow indefinitely until a later launch, contradicting the PR's stated requirement that device log storage remain bounded. Additionally, when a clock rollback makes the current session sort behind newer-stamped old directories, entries retained before reaching the current directory can leave the startup total above 100 MB as well. Add live file rotation or another enforceable current-session limit and ensure pruning cannot retain earlier history after the current directory pushes the total over quota.

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() }
Expand Down
Loading
Loading