Logs are one of the most common places sensitive data leaks from an iOS app. They end up in sysdiagnoses, support emails, crash tickets and bug trackers, and a single print("\(user)") or "token=\(token)" can put an email address or a bearer token in all of them.
I wanted Apple's unified logging, optional log files users can send to support, and real privacy guarantees, without pulling in a third-party SDK. So I built AppLoggerKit: a free, MIT-licensed logging and local diagnostics package for Swift, SwiftUI and UIKit with no third-party dependencies.
What's inside
| Module | What it gives you |
|---|---|
AppLoggerCore |
AppLogger with seven levels, lazy messages, categories and scoped loggers; structured LogRecord with a versioned JSON schema; public / private / sensitive metadata; a Redactor; LogContext for correlation IDs. Foundation only. |
AppLoggerOSLog |
os.Logger output with level mapping, Console.app filtering and OSLog privacy annotations. |
AppLoggerFile |
JSON Lines files with size/time rotation and retention, streaming search, paging, statistics, and redacted JSON Lines / text / ZIP exports. |
AppLoggerUI |
Optional SwiftUI and UIKit log viewer, live console, ShareLink export button and \.appLogger in the environment. |
AppLoggerTesting |
In-memory and failing destinations plus AppLogger.capturing() for your tests. |
Requirements: iOS 16+, iPadOS 16+, Mac Catalyst 16+, macOS 13+. Xcode 16 or later, Swift 6 toolchain.
Install
.package(url: "https://github.com/eeshuu/AppLoggerKit.git", from: "1.0.0"),
// …
.target(name: "App", dependencies: [
.product(name: "AppLoggerCore", package: "AppLoggerKit"),
.product(name: "AppLoggerOSLog", package: "AppLoggerKit"),
.product(name: "AppLoggerFile", package: "AppLoggerKit"), // only if you want log files
]),
Quick start
import AppLoggerCore
import AppLoggerFile
import AppLoggerOSLog
let fileLog = FileLogDestination(configuration: .default) // opt-in: no files without it
let logger = AppLogger(configuration: .init(
subsystem: "com.example.myapp",
minimumLevel: .debug,
categoryLevels: ["network": .trace],
destinations: [OSLogDestination(), fileLog]
))
logger.info("Application launched", category: "lifecycle")
logger.error("Unable to load profile", metadata: ["feature": "profile", "attempt": 2], error: error)
Messages and metadata are autoclosures, so a call below the active level never builds its string.
Privacy is the main feature
Nothing is captured automatically. No request or response bodies, headers, device identifiers or user data. Only what you pass is logged.
Metadata has a privacy class, enforced by the compiler. Literals are public; runtime values must say what they are, so "\(user.email)" doesn't compile as metadata:
logger.notice("Signed in", metadata: [
"method": "password", // public literal
"userID": .private(user.id), // shown in live outputs, redacted from files and exports
"token": .sensitive(token), // never reaches any destination
"endpoint": .url(request.url!), // credentials, fragment and query values stripped
])
Redaction runs before every destination. Keys like authorization, token, password, secret, cookie and api_key are redacted whatever their class, and bearer tokens, JWTs and password=… patterns are scrubbed from messages, values and error descriptions. You can add your own rules.
Files and exports are stricter. Private values are redacted by default, and an export re-applies redaction to whatever was stored.
Correlation IDs that follow async code
try await LogContext.withCorrelationID(requestID) {
logger.info("Fetching profile")
try await api.loadProfile() // every log inside, at any depth, carries the ID
}
Search and export logs on device
Perfect for a "Send logs to support" button:
let reader = LogReader(store: fileLog.store)
let recentErrors = try await reader.tail(matching: LogQuery(minimumLevel: .error, since: .now - 3600))
await logger.flush()
let export = try await LogExporter(store: fileLog.store).export(LogExportRequest(
format: .zip, // logs.jsonl + logs.txt + manifest.json
query: LogQuery(since: .now - 86_400),
maxBytes: 5_000_000,
note: "Ticket 4821"
))
// Present export.url with ShareLink or UIActivityViewController(logExport: export).
Built for Swift 6 concurrency
Every API is safe from any thread or actor. Log calls never block on I/O: records are queued under a lock and delivered in order by one background task. OSLogDestination writes on the calling thread so the last records before a crash aren't lost. Levels can be changed at runtime from a debug menu or remote flag without touching call sites, and a destination that throws never affects the call site.
Testable
import AppLoggerTesting
@Test func loadingLogsProgress() async {
let (logger, capture) = AppLogger.capturing()
await ProfileRepository(logger: logger).load()
await logger.flush()
#expect(capture.messages == ["Loading profile", "Profile loaded"])
}
What it deliberately doesn't do
No hosted dashboards or remote ingestion, no analytics or behaviour tracking, no crash reporting, and no encrypted storage: log files are ordinary local files protected by iOS file protection and excluded from backups. If you need to store secrets, that's what my other package, SecureStorageKit, is for.
The repo includes a sample app (Examples/LoggerDemo) and docs on architecture, privacy and the file format.
github.com/eeshuu/AppLoggerKit: MIT licensed. Stars, issues and feedback are very welcome.
My other Swift packages
- SecureStorageKit: Keychain, Face ID and Secure Enclave storage
- DesignSystemKit: one design system for SwiftUI and UIKit, with runtime white-label theming
Top comments (0)