A reusable iOS package that gives any app:
- A persistent logger (swift-log + GRDB-backed SQLite) that survives app relaunches.
- A drop-in SwiftUI sheet that lets users share, mail, copy, or clear the captured logs.
Built so any client app can wire it up with one config struct, no further glue code, and no host-specific assumptions.
- iOS 17+ (or macOS 14+ for the core module)
- Swift 5.9+
- Xcode 15+
- The app's Info.plist must declare
NSAppTransportSecurity/ mail entitlements as usual forMFMailComposeViewController.
File → Add Package Dependencies… →
git@github.com:anthony1810/SendLogsKit.git
Pick the version (e.g. 1.0.0, exact). Add the products you need:
SendLogsCore— the logger + SQLite store.SendLogsUI— the "Send Logs" SwiftUI sheet (depends onSendLogsCore).
dependencies: [
.package(url: "git@github.com:anthony1810/SendLogsKit.git", exact: "1.0.0"),
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "SendLogsCore", package: "SendLogsKit"),
.product(name: "SendLogsUI", package: "SendLogsKit"),
]
),
]import SwiftUI
import SendLogsCore
@main
struct MyApp: App {
init() {
let config = SendLogsConfig(
recipients: ["support@yourdomain.com"],
subjectPrefix: "[Logs] MyApp Report",
appName: "MyApp"
)
let sqliteService = try! SQLiteLoggingService(config: config)
UnifiedLogger.shared.configure(services: [sqliteService], config: config)
}
var body: some Scene {
WindowGroup { ContentView() }
}
}Call configure exactly once. It bootstraps swift-log's LoggingSystem, opens the SQLite store, and schedules a background sweep of expired logs.
import Logging
import SendLogsCore
Logger.info.log(category: .general, "User signed in")
Logger.error.log(category: .networking, "Request failed: \(error)")The convenience accessors Logger.debug / .info / .error / .critical route through UnifiedLogger to every registered service. Five sensible defaults ship out of the box (.general, .networking, .event, .database, .ui) — see Categories below for how to add your own.
You can also use plain swift-log directly — LoggingSystem is bootstrapped, so Logger(label: "anything").info("…") writes to the same store.
A category is just a string label that gets attached to each log record. Built-in categories live on LogCategory:
public extension LogCategory {
static let general: LogCategory = "general"
static let networking: LogCategory = "networking"
static let event: LogCategory = "event"
static let database: LogCategory = "database"
static let ui: LogCategory = "ui"
}Add your own by extending LogCategory — works because it conforms to ExpressibleByStringLiteral:
extension LogCategory {
static let ditto: LogCategory = "ditto"
static let gameday: LogCategory = "gameday"
}
Logger.info.log(category: .ditto, "subscription started")Or define a typed enum for your app, conforming to LogCategoryRepresentable:
enum AppCategory: String, LogCategoryRepresentable {
case ditto, gameday, sync
}
Logger.info.log(category: AppCategory.gameday, "period changed")Both approaches reach the same LoggingSystem and end up in the same SQLite store. Categories don't need to be pre-registered — swift-log handlers are looked up by label on demand.
import SendLogsUI
struct SettingsView: View {
let config: SendLogsConfig
@State private var showSendLogs = false
var body: some View {
Button("Send Logs") { showSendLogs = true }
.sheet(isPresented: $showSendLogs) {
SendLogsView(config: config)
}
}
}The sheet exposes four actions:
- Share Logs — opens
UIActivityViewControllerwith the SQLite file. - Send Mail — opens
MFMailComposeViewControllerpre-populated with the configured recipients, subject and a body containing device + version info. - Copy File URL — copies the on-disk path to the pasteboard (useful in dev).
- Clear Logs — wipes every row from the store.
SendLogsConfig is a Sendable value type — share one instance between the logger setup and the UI:
| Field | Required | Default | Purpose |
|---|---|---|---|
recipients |
yes | — | MFMailComposeViewController to: list. Pass [] to disable the mail button effectively. |
subjectPrefix |
yes | — | Mail subject. Date is appended as "\(prefix): \(date)". |
appName |
yes | — | Used in the mail body template ("Please find the diagnostic logs for \(appName) attached."). |
folderName |
no | "SendLogsKit" |
Subfolder under Application Support where logs.sqlite lives. Change this when shipping multiple apps that share an app group. |
rollingFrequencyDays |
no | 3 |
Number of days to retain logs. Anything older is deleted on configure. |
contextProvider |
no | nil |
@Sendable () -> [String: String] evaluated on every log insert. Returned key/value pairs are merged into the log's metadata column. Use this to attach userId / teamId / build flavour / anything else you'd want to grep on later. |
let config = SendLogsConfig(
recipients: ["support@yourdomain.com"],
subjectPrefix: "[Logs] MyApp",
appName: "MyApp",
contextProvider: { [
"userId": Session.current?.userId ?? "",
"teamId": Session.current?.teamId ?? "",
] }
)Every log row's metadata JSON now carries those fields automatically — no per-call wrapping needed.
SQLiteLoggingService is one implementation of LoggerServiceProtocol. You can register additional services (e.g. forward errors to Mixpanel/Sentry, mirror to OSLog) by conforming to the protocol:
actor MixpanelLoggingService: LoggerServiceProtocol {
func insert(_ log: LogModel) throws {
guard log.logLevel == .error || log.logLevel == .critical else { return }
Mixpanel.mainInstance().track(event: "Log", properties: ["message": log.message])
}
}
UnifiedLogger.shared.configure(
services: [
try SQLiteLoggingService(config: config),
MixpanelLoggingService(),
],
config: config
)Default protocol methods make configure / exportLogs / clearExpiredLogs / clearAllLogs optional — implement only what your service needs.
Logs live at:
{Application Support}/{config.folderName}/logs.sqlite
The directory is created on first launch with extensionHidden: true and protectionKey: .complete. Both the directory and the file are excluded from iCloud backups by virtue of being in Application Support and using complete file protection.
- Mail entitlement —
MFMailComposeViewControlleris unavailable in the Simulator without an account configured. The sheet falls back to a friendly message in that case. - Sharing —
UIActivityViewControllerwill pass the raw.sqlitefile. Most modern email clients (Gmail, Outlook) will attach it. AirDrop works to a Mac for local inspection. - Schema version — the store erases itself on schema upgrades (
SQLiteLoggingService.schemaVersion). Stored inUserDefaultsundersendlogskit.schema.version. - SwiftUI preview safety —
UnifiedLogger.configure(...)is a no-op inside Xcode SwiftUI previews so previews don't write to disk. configureis one-shot — calling it twice triggers anassertionFailurein DEBUG (silent in release). Wire it from yourApp.initor app delegate'sdidFinishLaunching.
Semantic versioning. Pin to an exact version in your Package.swift and bump intentionally.
MIT. See LICENSE.