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
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Foundation
import os

// MARK: - APIClientProtocol

Expand Down Expand Up @@ -83,7 +82,7 @@ public final class APIClient: APIClientProtocol {
private let decoder: JSONDecoder
private let encoder: JSONEncoder
private let retryPolicy: RetryPolicy
private let logger: Logger
private let appLog: AppLog

public init(
baseURL: URL = URL(string: "https://interlinedlist.com")!,
Expand All @@ -99,10 +98,7 @@ public final class APIClient: APIClientProtocol {
self.decoder = decoder
self.encoder = encoder
self.retryPolicy = retryPolicy
self.logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.interlinedlist.kit",
category: "APIClient"
)
self.appLog = AppLog(category: "APIClient")
}

// MARK: APIClientProtocol
Expand All @@ -114,9 +110,13 @@ public final class APIClient: APIClientProtocol {
do {
return try decoder.decode(Response.self, from: data)
} catch {
// Log the full decoder detail (coding path / key) with the request
// path — the user only ever sees `APIError.userFacingMessage`.
let detail = String(reflecting: error)
appLog.error("Decode failed [\(request.path)] type=\(String(describing: Response.self)): \(detail)")
throw APIError.decoding(
type: String(describing: Response.self),
message: error.localizedDescription
message: detail
)
}
}
Expand All @@ -141,9 +141,13 @@ public final class APIClient: APIClientProtocol {
// that is the correct "no limit on this route" signal.
return (decoded, RateLimitInfo.parse(from: response))
} catch {
// Log the full decoder detail (coding path / key) with the request
// path — the user only ever sees `APIError.userFacingMessage`.
let detail = String(reflecting: error)
appLog.error("Decode failed [\(request.path)] type=\(String(describing: Response.self)): \(detail)")
throw APIError.decoding(
type: String(describing: Response.self),
message: error.localizedDescription
message: detail
)
}
}
Expand All @@ -166,7 +170,7 @@ public final class APIClient: APIClientProtocol {
// transparently try once via the session transport before we
// give up. This catches future API drift in either direction.
if case .unauthorized = error, request.auth == .bearer {
logger.warning("Bearer request returned 401 — retrying via session transport")
appLog.warning("Bearer request returned 401 [\(request.path)] — retrying via session transport")
return try await performWithRetry(request, forceSession: true)
}
throw error
Expand Down Expand Up @@ -220,13 +224,16 @@ public final class APIClient: APIClientProtocol {
// and be indistinguishable from a genuine transport failure).
throw CancellationError()
} catch {
appLog.error("Transport failed [\(request.path)]: \(String(reflecting: error))")
throw APIError.transport(message: error.localizedDescription)
}

guard (200..<300).contains(response.statusCode) else {
let serverMessage = decodeServerMessage(from: data)
appLog.notice("HTTP \(response.statusCode) [\(request.path)]: \(serverMessage ?? "no server message")")
throw APIError.from(
statusCode: response.statusCode,
serverMessage: decodeServerMessage(from: data),
serverMessage: serverMessage,
retryAfter: parseRetryAfter(response.value(forHTTPHeaderField: "Retry-After"))
)
}
Expand Down
40 changes: 36 additions & 4 deletions Packages/InterlinedKit/Sources/InterlinedKit/Errors/APIError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,43 @@ extension APIError: Equatable {
}

extension APIError: LocalizedError, CustomStringConvertible {
/// The server-supplied human message if one is available, otherwise a
/// concise developer-facing description of the case. Suitable for both
/// `NSAlert` body text and `os.Logger` output.
public var errorDescription: String? { description }
/// The message shown to the user. Points at `userFacingMessage` so that
/// anything rendering `error.localizedDescription` (the whole app does)
/// gets friendly, non-technical copy. The technical form lives in
/// `description` and is what goes to the debug log.
public var errorDescription: String? { userFacingMessage }

/// A friendly, non-technical message safe to show in a loading/error UI.
///
/// Server-supplied messages (400/403/404/429) are already human-written on
/// InterlinedList and are preserved verbatim. The client-only cases
/// (`decoding`, `transport`) and bare status codes — whose `description`
/// is developer jargon like "Decoding ListRowDTO failed: …" — get a
/// generic, reassuring message instead. The real cause is captured in the
/// debug log via `description`.
public var userFacingMessage: String {
switch self {
case .transport:
return "Can’t reach InterlinedList. Check your internet connection and try again."
case .decoding:
return "InterlinedList sent back something we couldn’t read. Please try again in a moment."
case .unauthorized(let message):
return message ?? "Your session has expired. Please sign in again."
case .forbidden(let message):
return message ?? "You don’t have permission to do that."
case .notFound(let message):
return message ?? "We couldn’t find what you were looking for."
case .badRequest(let message):
return message ?? "That request couldn’t be completed. Please check your input and try again."
case .rateLimited(let message, _):
return message ?? "You’re doing that a little too quickly. Please wait a moment and try again."
case .httpStatus(_, let message):
return message ?? "Something went wrong. Please try again."
}
}

/// The technical description — developer jargon, request/decoder detail —
/// used for `os.Logger` and the debug-log file. Never shown to the user.
public var description: String {
switch self {
case .transport(let message):
Expand Down
161 changes: 161 additions & 0 deletions Packages/InterlinedKit/Sources/InterlinedKit/Logging/AppLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import Foundation
import os

/// App-wide logging facade.
///
/// Every call mirrors to Apple's unified logging (visible live in Console.app
/// and `log stream`) **and** appends to a rotating file under the app's
/// container so a user can send it for debugging later. Under the sandbox the
/// file lives at:
///
/// ~/Library/Containers/<bundle-id>/Data/Library/Logs/InterlinedList/interlinedlist.log
///
/// The unified-logging side keeps the existing on-device behaviour; the file
/// side is the new, retrievable artifact. User-facing UI text is produced
/// separately (see `APIError.userFacingMessage`) — the log holds the full
/// technical detail, the UI shows the friendly message.
public struct AppLog: Sendable {

/// Log severity. Mirrors the `os.Logger` levels we actually use.
public enum Level: String, Sendable {
case debug, info, notice, warning, error, fault
}

/// The unified-logging subsystem shared across the app and its packages.
public static let subsystem = "com.interlinedlist.macos"

private let category: String
private let osLogger: Logger
private let file: FileLog

/// - Parameters:
/// - category: groups related messages (e.g. `"APIClient"`).
/// - file: the file sink. Defaults to the process-wide `.shared` log so
/// every category writes to the same file; injectable for tests.
public init(category: String, file: FileLog = .shared) {
self.category = category
self.osLogger = Logger(subsystem: AppLog.subsystem, category: category)
self.file = file
}

public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
public func warning(_ message: @autoclosure () -> String) { log(.warning, message()) }
public func notice(_ message: @autoclosure () -> String) { log(.notice, message()) }
public func info(_ message: @autoclosure () -> String) { log(.info, message()) }
public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) }

public func log(_ level: Level, _ message: String) {
// `.public` privacy: these strings are already scrubbed of user data
// by the callers (we log error *structure*, not response bodies).
switch level {
case .debug: osLogger.debug("\(message, privacy: .public)")
case .info: osLogger.info("\(message, privacy: .public)")
case .notice: osLogger.notice("\(message, privacy: .public)")
case .warning: osLogger.warning("\(message, privacy: .public)")
case .error: osLogger.error("\(message, privacy: .public)")
case .fault: osLogger.fault("\(message, privacy: .public)")
}
file.append(level: level, category: category, message: message)
}
}

/// The rotating file sink behind `AppLog`.
///
/// Thread-safety is provided by a private serial queue; writes are
/// fire-and-forget so logging never blocks the caller. `@unchecked Sendable`
/// is sound because every stored property is immutable and the only mutable
/// state (the file on disk, the shared date formatter) is touched solely on
/// `queue`.
public final class FileLog: @unchecked Sendable {

/// The process-wide log used by `AppLog` when no sink is injected.
public static let shared = FileLog()

private let queue = DispatchQueue(label: "com.interlinedlist.filelog")
private let fileURL: URL?
private let maxBytes: Int
private let formatter: ISO8601DateFormatter

/// - Parameters:
/// - directory: where the log file is written. `nil` disables file
/// logging entirely (every `append` becomes a no-op) — used under
/// XCTest so unit runs never touch the real `~/Library`.
/// - fileName: the active log file's name.
/// - maxBytes: rotate once the active file reaches this size. One
/// previous generation is kept as `<fileName>.1`.
public init(
directory: URL? = FileLog.defaultDirectory(),
fileName: String = "interlinedlist.log",
maxBytes: Int = 5 * 1024 * 1024
) {
self.maxBytes = maxBytes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.formatter = formatter

if let directory {
try? FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
self.fileURL = directory.appendingPathComponent(fileName)
} else {
self.fileURL = nil
}
}

/// The app's log directory inside the (sandbox) container's `Library/Logs`,
/// or `nil` when running under XCTest so tests don't write to real Library.
public static func defaultDirectory() -> URL? {
if isRunningUnderTests { return nil }
guard let library = FileManager.default
.urls(for: .libraryDirectory, in: .userDomainMask).first else { return nil }
return library.appendingPathComponent("Logs/InterlinedList", isDirectory: true)
}

/// True inside a unit-test host. `XCTestConfigurationFilePath` covers Xcode;
/// SwiftPM's `swift test` doesn't set it, so also sniff the loaded XCTest
/// runtime (never linked into the shipping app).
private static var isRunningUnderTests: Bool {
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|| NSClassFromString("XCTestCase") != nil
}

/// The active log file on disk, if file logging is enabled. Useful for a
/// future "Export Logs…" affordance.
public var currentFileURL: URL? { fileURL }

/// Appends one line: `<ISO-8601 timestamp> [LEVEL] <category>: <message>`.
public func append(level: AppLog.Level, category: String, message: String) {
guard let fileURL else { return }
let now = Date()
queue.async {
self.rotateIfNeeded(fileURL: fileURL)
let line = "\(self.formatter.string(from: now)) "
+ "[\(level.rawValue.uppercased())] \(category): \(message)\n"
guard let data = line.data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: fileURL) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
} else {
// File doesn't exist yet (first write, or just rotated).
try? data.write(to: fileURL, options: .atomic)
}
}
}

/// Rotates `interlinedlist.log` → `interlinedlist.log.1` once it grows past
/// `maxBytes`, discarding any older generation. Must run on `queue`.
private func rotateIfNeeded(fileURL: URL) {
let fm = FileManager.default
guard
let attributes = try? fm.attributesOfItem(atPath: fileURL.path),
let size = attributes[.size] as? Int,
size >= maxBytes
else { return }
let backup = fileURL.appendingPathExtension("1")
try? fm.removeItem(at: backup)
try? fm.moveItem(at: fileURL, to: backup)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,49 @@ final class APIErrorTests: XCTestCase {
XCTAssertEqual(error.description, "Unauthorized")
}

// MARK: - User-facing vs. technical messages

func test_givenDecodingError_whenUserFacing_thenHidesTechnicalDetail() {
let error = APIError.decoding(type: "ListRowDTO", message: "keyNotFound(CodingKeys(stringValue: \"id\"))")

// The UI (localizedDescription → errorDescription → userFacingMessage)
// must not leak the decoder jargon…
XCTAssertFalse(error.userFacingMessage.contains("ListRowDTO"))
XCTAssertFalse(error.userFacingMessage.lowercased().contains("decoding"))
XCTAssertFalse(error.userFacingMessage.lowercased().contains("keynotfound"))
XCTAssertEqual(error.errorDescription, error.userFacingMessage)

// …but the technical form (for the debug log) still carries it.
XCTAssertTrue(error.description.contains("ListRowDTO"))
XCTAssertTrue(error.description.contains("keyNotFound"))
}

func test_givenTransportError_whenUserFacing_thenIsFriendlyConnectionMessage() {
let error = APIError.transport(message: "The request timed out.")
XCTAssertFalse(error.userFacingMessage.lowercased().contains("network error"))
XCTAssertTrue(error.userFacingMessage.contains("connection"))
// Technical detail preserved for the log.
XCTAssertEqual(error.description, "Network error: The request timed out.")
}

func test_givenServerMessage_whenUserFacing_thenPreservesItVerbatim() {
// 4xx server messages are already human-written — keep them.
XCTAssertEqual(
APIError.forbidden(serverMessage: "Email not verified").userFacingMessage,
"Email not verified"
)
XCTAssertEqual(
APIError.badRequest(serverMessage: "Name is required").userFacingMessage,
"Name is required"
)
}

func test_givenStatusWithoutServerMessage_whenUserFacing_thenFriendlyFallback() {
let error = APIError.httpStatus(code: 500, serverMessage: nil)
XCTAssertFalse(error.userFacingMessage.contains("500"))
XCTAssertEqual(error.description, "HTTP 500")
}

// MARK: - APIErrorBody decoding

func test_givenErrorBodyJSON_whenDecoded_thenExtractsMessage() throws {
Expand Down
Loading
Loading