Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
3 changes: 2 additions & 1 deletion packages/swift-sdk/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ let package = Package(
.testTarget(
name: "SwiftDashSDKTests",
dependencies: ["SwiftDashSDK"],
path: "SwiftTests/SwiftDashSDKTests"
path: "SwiftTests/SwiftDashSDKTests",
resources: [.copy("Fixtures")]
),

// Integration tests against a local dashmate devnet.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,21 +213,51 @@ enum SDKLogFormatter {
}
}

private final class SDKLoggerState: @unchecked Sendable {
/// Internal rather than private so the pre-install buffer can be tested on a
/// fresh instance: the process-wide `SDKLogger.state` has no way back to the
/// "no sink installed" condition once any test has installed one.
final class SDKLoggerState: @unchecked Sendable {
/// How many pre-install events are retained for replay. A host that never
/// installs a sink must not accumulate lines for the life of the process,
/// so the buffer drops its oldest entries and reports the loss instead.
static let pendingLineLimit = 256

private let lock = NSLock()
private var sink: SDKLogFileSink?
private var includeDebug = false

func installSink(at sessionDirectory: URL, includeDebug: Bool) -> Bool {
/// Events emitted before the file sink exists. `DashModelContainer.create`
/// runs in the host's `init()`, long before `LoggingPreferences.configure()`
/// installs the sink, so without this buffer the store-open result — and
/// every other launch-path event — would only ever reach the console and
/// never the exported `swift/run.log`.
private var pendingLines: [(severity: SDKLogSeverity, line: String)] = []
private var droppedPendingLineCount = 0

/// Installs the sink and replays what was emitted before it existed, in
/// emission order and under the sink's own debug filter.
func installSink(at sessionDirectory: URL, includeDebug: Bool) -> (
installed: Bool,
droppedPendingLineCount: Int
) {
do {
let newSink = try SDKLogFileSink(sessionDirectory: sessionDirectory)
lock.withLock {
// Replay under the same lock `record` takes, so a line emitted
// concurrently with the install cannot land in front of the
// backlog it actually followed.
let dropped: Int = lock.withLock {
sink = newSink
self.includeDebug = includeDebug
for entry in pendingLines where entry.severity != .debug || includeDebug {
newSink.write(entry.line)
}
let droppedCount = droppedPendingLineCount
pendingLines = []
droppedPendingLineCount = 0
return droppedCount
}
return true
return (installed: true, droppedPendingLineCount: dropped)
} catch {
return false
return (installed: false, droppedPendingLineCount: 0)
}
}

Expand All @@ -237,16 +267,40 @@ private final class SDKLoggerState: @unchecked Sendable {
}
}

func destination(for severity: SDKLogSeverity) -> SDKLogFileSink? {
lock.withLock {
/// Routes one formatted line to the sink, or buffers it for replay when no
/// sink has been installed yet.
func record(severity: SDKLogSeverity, line: String) {
Comment thread
llbartekll marked this conversation as resolved.
let destination: SDKLogFileSink? = lock.withLock {
guard let sink else {
if pendingLines.count >= Self.pendingLineLimit {
pendingLines.removeFirst()
Comment thread
llbartekll marked this conversation as resolved.
Outdated
droppedPendingLineCount += 1
}
pendingLines.append((severity: severity, line: line))
return nil
}
guard severity != .debug || includeDebug else { return nil }
return sink
}
destination?.write(line)
}

func flush() {
lock.withLock { sink }?.flush()
}

/// Test seam: drop the sink and everything buffered for it. `record`
/// buffers process-wide while no sink exists and `installSink` replays
/// the whole backlog into whichever session installs first, so a suite
/// asserting over a complete `run.log` must start from nothing.
func reset() {
lock.withLock {
sink = nil
includeDebug = false
pendingLines = []
droppedPendingLineCount = 0
}
}
}

// MARK: - Logging Preferences
Expand Down Expand Up @@ -473,7 +527,7 @@ public enum SDKLogger {
redacting: sensitiveValues
)

state.destination(for: severity)?.write(line)
state.record(severity: severity, line: line)

let shouldMirrorToConsole: Bool
switch severity {
Expand All @@ -496,13 +550,36 @@ public enum SDKLogger {
}

static func installFileSink(at sessionDirectory: URL, includeDebug: Bool) -> Bool {
state.installSink(at: sessionDirectory, includeDebug: includeDebug)
let outcome = state.installSink(
at: sessionDirectory,
includeDebug: includeDebug
)
if outcome.droppedPendingLineCount > 0 {
// Emitted after the replay so the gap is visible at the point in
// the file where the missing lines would have been.
event(
"log_pre_install_buffer_overflow",
category: .lifecycle,
severity: .warning,
fields: [
"dropped_line_count": .integer(
Int64(outcome.droppedPendingLineCount)
),
]
)
}
return outcome.installed
}

static func updateDebugSetting(_ includeDebug: Bool) {
state.updateDebugSetting(includeDebug)
}

/// Tests only. See `SDKLoggerState.reset()`.
static func resetForTesting() {
state.reset()
}

public static func log(_ message: String, minimumLevel level: LoggingPreset = .medium) {
guard LoggingPreferences.allows(level) else { return }
// Mirror to NSLog (unified logging) in addition to stdout so
Expand Down
Loading
Loading