A lightweight, production-ready logging pipeline for iOS & macOS — one API, structured events, Swift 6 ready.
English | Tiếng Việt
Designed to be used everywhere (UI, Network, Business) without creating separate logger types. Every log call flows through one pipeline:
flowchart LR
A["logger.info(…)"] --> B["Level<br/>check"] --> C["Filters"] --> D["Sampling"] --> E["Redaction"] --> F{"Destinations"}
F -- "Pretty · debug+" --> G["🖥 Console"]
F -- "JSON · info+" --> H["📄 File"]
F -- "JSON · error+" --> I["☁️ Remote"]
- Features
- Requirements
- Installation
- Quick Start
- Recommended Production Setup
- Core Concepts
- Use Cases — 20 copy-paste recipes
- Formatters
- Sinks
- Performance & Reliability Notes
- Testing
- Roadmap
- License
Architecture deep dive → ARCHITECTURE.md
- One API for every layer — UI, Network, Business, System.
- Structured events — message + tags + typed context, queryable on any collector.
- Per-destination levels — console gets
debug+, fileinfo+, remoteerror+, from a single call. - Privacy built in — key-based redaction runs before anything is formatted or emitted.
- Production safety — sampling, backpressure with reported drops, file rotation with self-recovery.
- Crash safe —
fatalis synchronous;flush()drains everything on demand. - Near-zero cost when disabled —
@autoclosurefast path: belowminLevel, the message is never even built. - Swift 6 native — every public type is
Sendable; use it from any actor, task, or thread.
| Minimum | |
|---|---|
| iOS | 15.0 |
| macOS | 12.0 |
| Swift | 6.0 (SwiftPM tools 6.0) |
- File → Add Package Dependencies…
- Paste the repository URL into the search field:
https://github.com/konotori/LogPipe.git - Set the dependency rule to Up to Next Major Version from
1.0.0, then click Add Package.
Add the dependency to your Package.swift:
.package(url: "https://github.com/konotori/LogPipe.git", from: "1.0.0")Then add LogPipe to your target:
.target(
name: "MyApp",
dependencies: ["LogPipe"]
)import LogPipe
let logger = Logger(
config: LoggerConfiguration(minLevel: .debug),
destinations: [
LogDestination(formatter: PrettyLogFormatter(), sink: ConsoleLogSink())
]
)
logger.info("App started")
logger.debug("Cache hit", tags: ["SYSTEM"])
logger.error("Payment failed", tags: ["BUSINESS"], context: ["orderId": "A123"])Create one shared logger for the whole app (every Logger(...) creates its own queue and configuration — you almost always want exactly one), then derive child loggers per module with withTags/withContext:
import LogPipe
enum Log {
static let shared: Logger = {
#if DEBUG
// Development: see everything, readable output.
return Logger(
config: LoggerConfiguration(minLevel: .debug),
destinations: [
LogDestination(formatter: PrettyLogFormatter(), sink: ConsoleLogSink())
]
)
#else
// Production: unified logging + rotated file + errors to your crash reporter.
let logsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Logs")
let fileSink = FileLogSink(fileURL: logsDir.appendingPathComponent("app.log"))
let crashReporterSink = RemoteLogSink { formatted, event in
// Forward to Crashlytics / Sentry / your backend (see "Remote Logging" below)
}
return Logger(
config: LoggerConfiguration(minLevel: .info, samplingRate: 0.2),
destinations: [
LogDestination(formatter: PrettyLogFormatter(), sink: OSLogSink(subsystem: "com.your.app"), minLevel: .info),
LogDestination(formatter: JSONLogFormatter(), sink: fileSink, minLevel: .info),
LogDestination(formatter: JSONLogFormatter(), sink: crashReporterSink, minLevel: .error)
]
)
#endif
}()
// Per-module child loggers — same pipeline, pre-filled tags.
static let ui = shared.withTags(["UI"])
static let network = shared.withTags(["NETWORK"])
static let business = shared.withTags(["BUSINESS"])
}Because Logger is Sendable, the static let above is fully legal in Swift 6 and the logger can be used from any actor, task, or thread.
| Concept | What it is |
|---|---|
| LogEvent | the unit of logging — level, message, tags, context, time, thread, source |
| Logger | the public API your app calls |
| Pipeline | fast level check → filter → sampling → redact → format → emit |
| Destination | formatter + sink + per-destination minLevel |
📖 For a deep dive into every component, the full pipeline walk-through, the threading model, and level-choosing guidance, see ARCHITECTURE.md.
public enum LogLevel: Int, Comparable, Sendable {
case debug, info, warn, error, fatal
}public struct LoggerConfiguration: Sendable {
var minLevel: LogLevel // global floor (default .info)
var enabledTags: Set<String>? // nil = allow all
var redactKeys: Set<String> // case-insensitive key redaction
var samplingRate: Double // applies to debug/info only
var includeSourceInfo: Bool // file/function/line
var includeThread: Bool // "main" / "background", captured at call site
var maxQueuedEvents: Int // backpressure limit (default 1000)
var dateFormatStyle: Date.ISO8601FormatStyle
var dateProvider: @Sendable () -> Date
}Log.ui.info("Screen appeared", context: ["screen": "Home"])
Log.ui.info("Button tapped", context: ["button": "BuyNow", "screen": "ProductDetail"])Log.network.debug("Request", context: ["url": "https://api/login", "method": "POST"])
Log.network.info("Response", context: ["status": 200, "durationMs": 240])
Log.network.warn("Slow response", context: ["url": "https://api/feed", "durationMs": 4200])Log.business.info("Order created", context: ["orderId": "A123", "amount": 59.99])
Log.business.error("Payment failed", context: ["reason": "card_declined"])The error(_:error:) overload extracts a structured breakdown of any Error — so the whole team logs errors in one consistent, queryable shape:
do {
try await paymentService.charge(order)
} catch {
Log.business.error("Payment failed", error: error, context: ["orderId": order.id])
}
// Context automatically includes:
// error.type e.g. "URLError"
// error.domain e.g. "NSURLErrorDomain"
// error.code e.g. -1009
// error.description localized description
// User-provided context keys override the generated error.* keys.Attach identifiers once; every subsequent log carries them:
let userLogger = Log.shared.withContext(["userId": "u1", "sessionId": "s1"])
userLogger.info("Profile opened", tags: ["UI"])
// context = { userId: "u1", sessionId: "s1" }
// Call-site context merges with (and overrides) inherited context:
userLogger.info("Plan changed", context: ["plan": "pro"])let networkLogger = Log.shared.withTags(["NETWORK"])
networkLogger.info("Request started", context: ["url": "https://api"])
// tags = ["NETWORK"]Keys listed in redactKeys are masked case-insensitively and recursively (nested objects and arrays included) before formatting and emitting:
let logger = Logger(
config: LoggerConfiguration(redactKeys: ["token", "password", "email"])
)
logger.info("Login", context: [
"email": "a@b.com",
"password": "123",
"profile": ["token": "abc"] // nested keys are redacted too
])
// → email, password, profile.token all become "[REDACTED]"Important limitation: redaction matches context keys only. Values and the message string are never scanned.
logger.info("User \(email) logged in")ships the raw email — put sensitive data in context under a redacted key instead.
Keep a fraction of low-severity logs; warn/error/fatal are never sampled:
var config = LoggerConfiguration(minLevel: .debug)
config.samplingRate = 0.1 // keep ~10% of debug/info
let logger = Logger(config: config)
logger.debug("This may be sampled out")
logger.warn("This is always kept")The classic production split — verbose locally, lean remotely — with one log call:
let logger = Logger(
config: LoggerConfiguration(minLevel: .debug), // global floor
destinations: [
LogDestination(formatter: PrettyLogFormatter(), sink: ConsoleLogSink(), minLevel: .debug),
LogDestination(formatter: JSONLogFormatter(), sink: fileSink, minLevel: .info),
LogDestination(formatter: JSONLogFormatter(), sink: remoteSink, minLevel: .error)
]
)
logger.debug("Cache hit") // console only
logger.info("Order created") // console + file
logger.error("Payment failed") // console + file + remoteFileLogSink appends asynchronously, rotates by size (app.log → app.log.1 → app.log.2, ...), creates intermediate directories, and recovers automatically if the file is deleted from under it:
let logsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Logs")
let fileURL = logsDir.appendingPathComponent("app.log")
let fileSink = FileLogSink(fileURL: fileURL, maxFileSize: 5 * 1024 * 1024, maxArchivedFiles: 3)
let logger = Logger(
destinations: [LogDestination(formatter: JSONLogFormatter(), sink: fileSink)]
)A typical "Send logs to support" feature then just zips app.log + app.log.1...3 and attaches them to a support email — call logger.flush() first so nothing is still buffered.
OSLogSink forwards to the system's unified logging, so logs from a tester's or user's device show up in Console.app and in sysdiagnose archives, with correct level mapping (fatal → .fault):
let logger = Logger(
destinations: [
LogDestination(formatter: PrettyLogFormatter(),
sink: OSLogSink(subsystem: "com.your.app", category: "default"))
]
)Privacy note:
OSLogSinkmarks the formatted line asprivacy: .public— redaction has already run, so listed keys are safe, but anything you interpolate directly into the message goes to the unified log store in clear text (see the redaction limitation above).
In most production apps you do not ship every log to a server. The common setup is a crash reporter (Crashlytics, Sentry) where your logs become breadcrumbs attached to crash/error reports. Keep LogPipe as the single API your codebase calls, and adapt the SDK behind a sink:
// Crashlytics example: all logs become breadcrumbs, errors become non-fatal records.
let crashlyticsSink = RemoteLogSink { formatted, event in
Crashlytics.crashlytics().log(formatted)
if event.level >= .error {
let error = NSError(domain: "AppLog", code: 0,
userInfo: [NSLocalizedDescriptionKey: event.message])
Crashlytics.crashlytics().record(error: error)
}
}
let logger = Logger(destinations: [
LogDestination(formatter: PrettyLogFormatter(), sink: OSLogSink()),
LogDestination(formatter: JSONLogFormatter(), sink: crashlyticsSink, minLevel: .info)
])Benefits: vendor swaps touch one file, call sites never change, and your redaction runs before data leaves the app.
If you ship logs to your own backend, you also need batching, offline persistence, and retry — see Roadmap. Commercial SDKs (Datadog, Sentry) already implement that layer; prefer them unless your data must stay on your infrastructure.
// E.g. from a hidden debug menu or a remote-config flag:
logger.updateConfiguration { config in
config.minLevel = .debug // turn on verbose logging for this session
config.samplingRate = 1.0
}flush() synchronously drains all pending events and flushes every sink (including file buffers). Call it when the app is about to lose execution time:
// SwiftUI
.onChange(of: scenePhase) { _, phase in
if phase == .background { Log.shared.flush() }
}
// UIKit
func applicationDidEnterBackground(_ application: UIApplication) {
Log.shared.flush()
}fatal events are processed synchronously and flushed immediately, so they survive even if the app crashes on the next line:
guard let database = try? openDatabase() else {
Log.shared.fatal("Cannot open database", context: ["path": dbPath])
fatalError("Unrecoverable: database unavailable")
}At most maxQueuedEvents (default 1000) events are queued at any moment. Excess events are dropped, and the logger reports it honestly with a synthetic warning:
[WARN] Logger dropped 250 event(s) due to backpressure
var config = LoggerConfiguration()
config.maxQueuedEvents = 500 // tune if neededvar config = LoggerConfiguration(minLevel: .debug)
config.enabledTags = ["NETWORK"] // only NETWORK-tagged (and untagged) events passUntagged events always pass, so general logs are never accidentally silenced.
Every pipeline stage is a public protocol. A custom sink is a few lines:
struct AnalyticsSink: LogSink {
func emit(_ formatted: String, event: LogEvent) {
guard event.tags.contains("ANALYTICS") else { return }
Analytics.track(event.message, properties: event.context.mapValues { $0.toAny() })
}
}Available protocols: LogSink, LogFormatter, LogFilter, LogRedactor. All are Sendable; LogSink.flush() has a default no-op, so existing conformers keep compiling.
final class CapturingSink: LogSink, @unchecked Sendable {
private let queue = DispatchQueue(label: "tests.capturing")
private var events: [LogEvent] = []
func emit(_ formatted: String, event: LogEvent) { queue.sync { events.append(event) } }
func all() -> [LogEvent] { queue.sync { events } }
}
@Test func ordersAreLogged() {
let sink = CapturingSink()
let logger = Logger(destinations: [LogDestination(formatter: JSONLogFormatter(), sink: sink)])
OrderService(logger: logger).create()
logger.flush() // deterministic: no polling needed
#expect(sink.all().contains { $0.message == "Order created" })
}Inject a fixed dateProvider for snapshot-stable output: config.dateProvider = { Date(timeIntervalSince1970: 0) }.
All public types are Sendable. Use the logger freely across isolation domains:
let logger = Logger() // can be a global `static let`
actor OrderStore {
func save(_ order: Order) {
logger.info("Saving order", context: ["id": order.id]) // ✅ no warnings
}
}
Task.detached {
logger.debug("Background refresh started") // ✅ no warnings
}Thread info ("main"/"background") and timestamps are captured at the call site, so they describe where you logged from — not the logger's internal queue.
| Formatter | Output | Best for |
|---|---|---|
PrettyLogFormatter |
2026-06-07T10:00:00Z [ERROR][BUSINESS]{main} Payment failed {"orderId":"A123"} (Checkout.swift:42 pay()) |
local debugging |
JSONLogFormatter |
one JSON object per line, sorted keys, stable shape | files & remote collectors |
| Sink | Destination | Notes |
|---|---|---|
ConsoleLogSink |
print |
development |
OSLogSink |
unified logging | Console.app, sysdiagnose |
FileLogSink |
file | async appends, size-based rotation, self-recovery |
RemoteLogSink |
your closure | adapter for any SDK or backend |
- Message and context use
@autoclosure: logs belowminLevelcost almost nothing — no string building, no context conversion, no queue dispatch. - Timestamp and thread info are captured at the call site; the heavy pipeline runs on a background queue.
fatalevents are processed synchronously and flushed, so they survive an immediate crash.- Backpressure caps memory under log storms; drops are reported, never silent.
- Redaction happens before formatting and emit, and matches context keys only.
- Sampling only affects debug and info levels.
swift test- Batched remote sender with offline persistence and retry (for self-hosted backends).
- Payload truncation (size limits for huge context values).