The logging package iOS teams wish they'd written. Structured, multi-engine, Swift-Concurrency-native β with an in-app Instruments dashboard, zero-config live tail to your Mac, automatic PII redaction, Sentry/Datadog/Loki shippers, and Swift Macros. All in one package, all opt-in.
by Mohammed Elnaggar (@MoElnaggar14)
import SwiftMoLogger
// Day 1: it just works.
SwiftMoLogger.info("App started")
SwiftMoLogger.error("Payment failed", tag: .api, metadata: [
"order_id": "ord_4291",
"amount": 49.99
])import SwiftMoLoggerUI
// Day 2: drop one view, get Instruments inside your app.
DiagnosticsHubView()# Day 3: tail every device on your Wi-Fi from the terminal.
swift run swiftmologger-inspectorThat's it. No configure(β¦), no singletons to wire, no protocol gymnastics.
- Why SwiftMoLogger?
- Install
- Architecture at a glance
- The headline features
- Core logging
- Production hardening
- Distributed tracing
- Flight recorder
- Error grouping
- Swift Concurrency
- Performance
- Testing
- Comparison
- Migration from v2
- Development model (GitFlow)
- π Article series
- Xcode code snippets
- License
| What it solves | |
|---|---|
| π― | Zero ceremony. SwiftMoLogger.info("hi") works the moment you import. No configuration step. |
| π§© | Structured everywhere. Every call materialises a LogEntry with level + tag + metadata + source location + thread. No more parsing strings downstream. |
| π | Sub-Β΅s hot path. ~140 ns when no engines are attached, ~310 ns with a memory engine. See PERFORMANCE.md. |
| π‘ | Production-safe by default. Built-in PII / token / credit-card redaction. Rate limiting. Sampling. Privacy manifest. |
| π | Self-hosted observability. DiagnosticsHubView() is Instruments + Charles + Console inside your app. No cable, no Mac required. |
| π‘ | Zero-config live tail. Bonjour-advertised devices, terminal CLI on your Mac auto-discovers them all. |
| π° | W3C distributed tracing. Stamp every URLSession request with traceparent so iOS spans show up next to your backend trace. |
| πΌ | Flight recorder. Rolling 2-minute black box persisted to disk; replay the seconds before a crash on next launch. |
| πͺ | Smart error grouping. Spammy retries collapse into one card with a count, not 1 000 noise lines. |
| π§ͺ | First-class testing. Drop-in XCTest assertions over what was logged. |
| πͺΆ | Opt-in everything. 7 separate library products. Pay only for what you import. |
// Package.swift
dependencies: [
.package(url: "https://github.com/MoElnaggar14/SwiftMoLogger.git", from: "3.0.0")
],
targets: [
.target(name: "App", dependencies: [
.product(name: "SwiftMoLogger", package: "SwiftMoLogger"),
// β¦add only what you need:
.product(name: "SwiftMoLoggerUI", package: "SwiftMoLogger"),
.product(name: "SwiftMoLoggerNetwork", package: "SwiftMoLogger"),
.product(name: "SwiftMoLoggerRemote", package: "SwiftMoLogger"),
.product(name: "SwiftMoLoggerDiagnostics", package: "SwiftMoLogger"),
.product(name: "SwiftMoLoggerTesting", package: "SwiftMoLogger"),
.product(name: "SwiftMoLoggerSugar", package: "SwiftMoLogger"),
])
]βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β YOUR APP β
β SwiftMoLogger.info("...", tag: .api, metadata: ...) β
ββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ
β
ββββββββββββββββΌβββββββββββββββ
β EngineRegistry β os_unfair_lock
β (level filter + fan-out) β ~140 ns hot path
ββββββββ¬ββββββ¬ββββββ¬ββββββ¬βββββ
ββββββββββββββββ β β ββββββββββββββββ
β β β β
βββββΌββββ βββββββΌββ βββΌβββββββ ββββββΌβββββ
βSystem β βMemory β β File β β¦ β Custom β
βLogger β βEngine β βEngine β β Engine β
βββββββββ βββββββββ ββββββββββ βββββββββββ
β β β β
os.Logger ring JSONL Sentry /
buffer rotation Datadog /
Loki / WS / β¦
Decorators (Redacting, Sampling, RateLimiting) wrap any engine. Streams (AsyncStream<LogEntry>, Combine Publisher) tap the registry. The SwiftUI Hub reads from shared NetworkEventStore / SignpostEventStore / VitalsHistoryStore / BreadcrumbStore.
| Product | What you get |
|---|---|
SwiftMoLogger |
Core: levels, tags, metadata, engines, registry, MetricKit, breadcrumbs, redaction, sampling, rate-limiting, Combine, signposts |
SwiftMoLoggerUI |
SwiftUI console (LogConsoleView) + DiagnosticsHubView (the headline) |
SwiftMoLoggerNetwork |
URLProtocol that auto-logs every URLSession request |
SwiftMoLoggerRemote |
HTTPLogShipper + ready-made SentryLogEngine / DatadogLogEngine / LokiLogEngine |
SwiftMoLoggerDiagnostics |
LiveSink (Bonjour), AppVitalsMonitor, BugReporter, WebSocketTailEngine |
SwiftMoLoggerTesting |
XCTAssertLogged + RecordingLogEngine |
SwiftMoLoggerSugar |
#log / #measure / @AutoLog Swift Macros |
swiftmologger-inspector |
Mac CLI executable for live tail |
One SwiftUI view that turns any build into a self-hosted observability cockpit. No cable, no Mac, no Xcode β just open the app.
import SwiftMoLoggerUI
struct DebugTab: View {
var body: some View { DiagnosticsHubView() }
}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π Diagnostics Hub π 421 π 38 γ° 12 [π clear] β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β 14:22:01 ββββββββ ββββββββ ββββββββ ββββββ β 14:23:01 β
β βββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β [π Logs] [π Network] [γ° Signposts] [π Vitals] [π Crumbs] β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β βΆ GET /v1/users ββββββββββ 142ms [200] β
β βΆ POST /v1/checkout ββββββββββ 423ms [201] β
β βΆ GET /v1/products βββββββββββ 891ms [500] β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
You get:
- Timeline scrubber with log-density bar β rewind up to 10 minutes
- Network waterfall of every URLSession request (colour-coded by status)
- Signpost flame graph with automatic lane assignment
- Vitals charts (memory / CPU / FPS / thermal) via Swift Charts
- Breadcrumb trail with category-coloured pins
The on-device LiveSink advertises a Bonjour service. The bundled Mac CLI discovers every device on the network and pretty-prints every log line.
#if DEBUG
import SwiftMoLoggerDiagnostics
let sink = LiveSink()
try sink.start()
SwiftMoLogger.addEngine(sink)
#endif$ swift run swiftmologger-inspector
SwiftMoLogger Inspector β discovering _swiftmologger._tcp on local networkβ¦
β discovered MyApp-iPhone-15
β discovered MyApp-iPad-Pro
β connected MyApp-iPhone-15
β connected MyApp-iPad-Pro
14:22:01.124 INFO MyApp-iPhone-15 [API] HTTP response status=200 duration_ms=132
14:22:01.221 WARN MyApp-iPad-Pro [Layout] Auto-layout broke 3 constraints
14:22:01.337 ERROR MyApp-iPhone-15 [Database] Migration v4 β v5 timed outMultiple devices, one terminal, no Xcode needed.
import SwiftMoLoggerSugar
#log("user signed in", level: .info, tag: .api)
// Captures #fileID / #function / #line at the call site.
let users = #measure("loadUsers") {
try repo.all()
}
// Lowers to LogSignpost.measure("loadUsers") { β¦ }
@AutoLog
final class CheckoutService {
func purchase(_ id: String) throws { β¦ }
}Macros live in a separate SwiftMoLoggerSugar product so the swift-syntax build cost is opt-in.
import SwiftMoLogger
// 8 levels mapped to OSLogType
SwiftMoLogger.trace("internals")
SwiftMoLogger.debug("only in DEBUG builds")
SwiftMoLogger.info("happy path")
SwiftMoLogger.notice("worth noticing")
SwiftMoLogger.warn("looks off")
SwiftMoLogger.error("broke")
SwiftMoLogger.critical("badly broke")
SwiftMoLogger.fault("unrecoverable")
// Errors with auto-metadata
SwiftMoLogger.error(error, tag: .api)
// β metadata.error_type, metadata.error captured automatically
// Tagged with namespaces β code completion friendly
SwiftMoLogger.info("hit cache", tag: .Data.cache)
SwiftMoLogger.warn("slow query", tag: .Data.database)
SwiftMoLogger.info("custom", tag: .custom("Checkout", domain: "checkout"))
// Global level filter β short-circuits before any allocation
SwiftMoLogger.minimumLevel = .info // drops trace + debug everywhereSwiftMoLogger.addEngine(MemoryLogEngine(capacity: 1_000))
SwiftMoLogger.addEngine(try FileLogEngine(
fileURL: URL.documentsDirectory.appending(path: "app.log"),
maxFileSizeBytes: 2 * 1_048_576,
maxRotatedFiles: 3
))Write your own in 3 lines:
struct AnalyticsEngine: LogEngine {
func log(_ entry: LogEntry) {
guard entry.level >= .warning else { return }
Analytics.track(entry.message, properties: entry.metadata.storage)
}
}
SwiftMoLogger.addEngine(AnalyticsEngine())struct APIService: LogTagged {
var logTag: LogTag { .api }
}
let service = APIService()
service.logInfo("hit") // β automatically tagged [API]
service.logError(networkError) // β tag + structured error metadataEvery log line passes through a regex-based scrubber before it leaves your process.
SwiftMoLogger.enableRedaction() // one-line install over SystemLoggerDefault rules: JWT, Bearer / Basic tokens, AWS / GCP keys, emails, credit cards, phone numbers, IPv4, UUIDs. Walks metadata recursively. Custom rules:
var redactor = Redactor()
try redactor.add(Redactor.Rule(name: "ssn", pattern: #"\d{3}-\d{2}-\d{4}"#))
SwiftMoLogger.addEngine(RedactingLogEngine(wrapping: networkEngine, redactor: redactor))SwiftMoLogger.breadcrumb("user tapped Buy", category: .userAction)
SwiftMoLogger.breadcrumb("nav β checkout", category: .navigation)
// Attach to a crash report / bug report
let crumbs: [Breadcrumb] = SwiftMoLogger.breadcrumbs()Bounded ring buffer (default 100), O(1) append, Sendable value type matching the Sentry / Bugsnag shape so shipping is a 1:1 mapping.
// Keep 1% of trace logs in production
SwiftMoLogger.addEngine(SamplingLogEngine(
wrapping: fileEngine,
strategy: .perLevel(rates: [.trace: 0.01, .debug: 0.1])
))
// Cap any sink at 50 logs/sec with a 100-event burst
SwiftMoLogger.addEngine(RateLimitingLogEngine(
wrapping: networkEngine,
permitsPerSecond: 50,
burst: 100
))Token-bucket rate limiter, thread-local PRNG for sampling β both ~ns-class overhead.
import SwiftMoLoggerRemote
SwiftMoLogger.addEngine(SentryLogEngine(
dsn: URL(string: "https://[email protected]/456")!,
release: "1.4.2",
environment: "production"
))
SwiftMoLogger.addEngine(DatadogLogEngine(
apiKey: "<DD_API_KEY>",
site: .eu1,
service: "checkout"
))
SwiftMoLogger.addEngine(LokiLogEngine(
endpoint: URL(string: "https://loki.example.com/loki/api/v1/push")!,
labels: ["job": "ios", "env": "prod"]
))All shippers: batch (50β100), debounce (5 s), retry with exponential backoff, cap buffered entries on long offline spells. log() is O(1) β network happens off the caller's thread.
import SwiftMoLoggerNetwork
let config = URLSessionConfiguration.default
NetworkLogger.install(on: config)
let session = URLSession(configuration: config)
// Every request is now logged with method/URL/status/duration_ms
// + breadcrumbs are recorded. Sensitive headers stripped automatically.Authorization, Cookie, X-API-Key, X-Auth-Token and friends are stripped by default β extend NetworkLoggingProtocol.sensitiveHeaders to add more.
PrivacyInfo.xcprivacy ships in the package. It declares:
NSPrivacyTracking = false(no tracking)- No collected data types
- Approved API reasons: UserDefaults (CA92.1), FileTimestamp (C617.1), SystemBootTime (35F9.1)
App Store submissions pass without further work.
Stamp every log entry and every outbound URLSession request with a W3C traceparent header. Tie the iOS-side operation directly to the downstream backend trace in Datadog, Honeycomb, OpenTelemetry, etc.
SwiftMoLogger.withTrace {
SwiftMoLogger.info("starting checkout")
try await api.charge(order) // outgoing request gets traceparent: 00-<traceID>-<spanID>-01
try await api.confirm(order) // same trace, new child span
}TraceContext is a value type β generate fresh roots, spawn child spans, parse inbound headers:
let ctx = TraceContext.generate()
let parsed = TraceContext.parse(traceparent: incomingHeader)
let child = ctx.childSpan()Backed by @TaskLocal, so concurrent tasks see their own trace.
let recorder = FlightRecorder(window: 120, flushInterval: 2)
recorder.start()Persists a rolling 2-minute window of every signal (logs, breadcrumbs, network, signposts, vitals) to disk every 2 seconds. On next launch:
if let session = FlightRecorder.recoverLastSession() {
SwiftMoLogger.warn("Recovered crashed session: \(session.entries.count) entries")
uploader.attach(session)
}Returns non-nil only when the previous run never had a clean stop() β almost always a crash, OOM, or watchdog kill. The exact signals you wish you'd had, after the fact.
A 1000-occurrence retry-spam logged once with count = 1000:
SwiftMoLogger.addEngine(ErrorGroupingEngine(
wrapping: sentryShipper,
fingerprintMinLevel: .warning,
emitThreshold: 1 // emit first occurrence per fingerprint
))Fingerprints by normalising the message (UUIDs β <uuid>, hex blobs β <hex>, digit runs β #, quoted strings β "β¦"), then SHA-256 of the result. Different shapes stay distinct; identical-shape noise collapses.
let groups = grouper.snapshot()
// β ErrorGroup(count: 1024, exemplar: "User # timed out", firstSeen: β¦, lastSeen: β¦)SwiftMoLogger.withContext(["request_id": "req-42", "user_id": "u-123"]) {
SwiftMoLogger.info("fetching profile") // β inherits both keys
try await api.fetchProfile()
SwiftMoLogger.info("profile cached") // β still inherits
}
SwiftMoLogger.info("outside scope") // β cleanBacked by @TaskLocal β concurrent Tasks see their own scope without interfering.
Task {
for await entry in SwiftMoLogger.stream() where entry.level >= .error {
await reportToBackend(entry)
}
}SwiftMoLogger.publisher()
.filter { $0.level >= .error }
.sink { entry in /* β¦ */ }
.store(in: &cancellables)let users = try LogSignpost.measure("loadUsers", tag: .database) {
try userRepo.all()
}
let response = try await LogSignpost.measureAsync("uploadAvatar") {
try await uploader.send(image)
}One call emits both an os_signpost interval (visible in Instruments' Points of Interest) and a log entry with metadata.elapsed_ms.
Measured on M1 MacBook Pro, iOS 17 simulator, release build:
| Scenario | per-call median |
|---|---|
info("β¦") β no engines |
~140 ns |
info("β¦") β MemoryLogEngine only |
~310 ns |
info("β¦") filtered out by minimumLevel |
~35 ns |
info("β¦") β SystemLogger (os.log) |
~820 ns |
| Concurrent 8 threads Γ 2 000 calls | linear scaling, ~22 ms total |
Memory: LogEntry is 200 B on the stack with zero heap unless metadata is non-empty. MemoryLogEngine pre-allocates its ring buffer β zero growth, zero GC churn.
Full benchmarks + design rationale β PERFORMANCE.md.
import SwiftMoLoggerTesting
final class CheckoutTests: XCTestCase {
var logs: RecordingLogEngine!
override func setUp() {
logs = SwiftMoLogger.installRecorder()
}
func testFailureIsLogged() async throws {
try await service.purchase(invalid: true)
XCTAssertLogged(.error, contains: "declined", tag: .api, in: logs)
XCTAssertLogCount(0, atLevel: .fault, in: logs)
}
}RecordingLogEngine captures everything; assertions are simple, scoped to a single test, and zero-config.
| SwiftMoLogger | os.Logger | SwiftyBeaver | CocoaLumberjack | |
|---|---|---|---|---|
Structured LogEntry |
β | β (string) | ||
| Multi-engine fan-out | β | β | β | β |
AsyncStream<LogEntry> |
β | β | β | β |
| Combine publisher | β | β | β | β |
| In-app Instruments view | β | β | β | β |
| Bonjour live tail | β | β | β | β |
| Built-in PII redaction | β | β | β | β |
| Breadcrumbs | β | β | β | β |
Auto URLSession capture |
β | β | β | β |
| Sentry / Datadog / Loki | β | β | β | |
| Sampling + rate limit | β | β | β | β |
| App vitals (CPU/FPS/mem) | β | β | β | β |
| Swift Macros | β | β | β | β |
XCTAssertLogged |
β | β | β | β |
| Privacy manifest | β | n/a | β | β |
| Task-local context | β | β | β | β |
W3C traceparent propagation |
β | β | β | β |
| Flight recorder | β | β | β | β |
| Smart error grouping | β | β | β | β |
| Xcode code snippets bundled | β | β | β | β |
| Hot path (no engines) | ~140 ns | ~120 ns | ~3 Β΅s | ~2 Β΅s |
| v2 | v3 | Notes |
|---|---|---|
LogEngine.info(message:) |
LogEngine.log(_:) |
v2 methods kept as default-impls |
LogTag is enum |
LogTag is struct + namespaces |
All .api shorthands preserved |
getAllEngines() |
allEngines() |
Old name kept as deprecated alias |
| info/warn silently dropped in release | always shipped | Real bug fix |
| no metadata | metadata: [:] on every call |
|
| no source location | captured via #fileID / #line |
automatic |
| no AsyncStream | SwiftMoLogger.stream() |
|
| no signpost integration | LogSignpost.measure |
|
| no SwiftUI console | LogConsoleView, DiagnosticsHubView |
| Branch | Purpose | Direct push? |
|---|---|---|
main |
tagged releases only | β release PR |
develop |
integration | β via PR |
feature/* |
new features β develop | merge to develop |
bugfix/* |
bug fixes β develop | merge to develop |
release/* |
release prep β main + develop | merge both |
hotfix/* |
emergency from main | merge both |
Branch policy is enforced by .github/workflows/gitflow.yml. Full procedure β GITFLOW.md.
A 5-part deep-dive on the rewrite, the design choices, and the production playbook. Read in order or jump to whichever is on fire for you today.
| # | Title | What you'll learn |
|---|---|---|
| 1 | Why I rewrote iOS logging from scratch | The shortcomings of print / os.Logger / SwiftyBeaver, and the design principles behind v3 |
| 2 | Sub-Β΅s logging: the performance design | Why the hot path is ~140 ns β locking choices, autoclosure tricks, allocation budgets |
| 3 | Instruments in your app: building Diagnostics Hub | How the timeline + waterfall + flame graph + vitals charts compose |
| 4 | Zero-config debugging with Bonjour and Swift Macros | The Mac CLI live tail, the macros target, and the dev-experience wins |
| 5 | The production playbook: tracing, redaction, flight recorder | The features that save you on the 3 AM call |
Series index: Articles/README.md.
Five .codesnippet files in Extras/Snippets/ for the calls you'll type most often:
| Prefix | Expands to |
|---|---|
smlinfo |
SwiftMoLogger.info(β¦, tag:, metadata:) |
smlerror |
SwiftMoLogger.error(error:, tag:, metadata:) |
smlmeasure |
LogSignpost.measure("name", tag: .performance) { β¦ } |
smlcontext |
SwiftMoLogger.withContext(β¦) { β¦ } |
smlcrumb |
SwiftMoLogger.breadcrumb(β¦, category: .userAction) |
Install:
cp Extras/Snippets/*.codesnippet ~/Library/Developer/Xcode/UserData/CodeSnippets/Restart Xcode. The snippets show up in the Snippets Library (ββ§L) and autocomplete by prefix.
MIT. See LICENSE.
Built with care by @MoElnaggar14. If it helped you ship faster, drop a β.