XcodeLogger 2.2.0 is the Swift-first replacement for the legacy Objective-C Xcode Logger project.
This version keeps the familiar ideas:
- readable developer-focused log output
- compatibility routing for
XLog,DLog,DVLog,DDLog, andNLog - file-level and global level filtering
It deliberately changes one important architectural point from the legacy project:
- build-configuration behavior is no longer encoded inside
XcodeLogger - your app owns that decision in its own source using compile-time conditions such as
#if DEBUG XcodeLoggeronly consumes the result as a safe on/off policy
That is the main migration path away from the old Info.plist scheme-linking model.
Version 2.2.0 expands the Swift package around a synchronous core and asynchronous sink delivery model.
- scoped child loggers through
category(_:)andscoped(...) - per-sink policy for minimum levels, regex category rules, file overrides, sampling, and rate limiting
- redaction for metadata keys and message bodies before sink rendering
- async-capable sinks with a shared serial delivery coordinator for deterministic ordering
- new
FileSinkwith size-based rotation and archive retention - shipped
TestSinkfor package consumers writing logger tests - simplified build gating with
LoggerConfiguration.whenEnabled(_:) - legacy Objective-C implementation tree removed from the repository, while
Compatibility/XcodeLogger.hremains supported
The legacy implementation combined two concerns:
- legacy log-family calls such as
DLog,DVLog,DDLog,NLog, andXLog - app-specific decisions about which builds should emit logs
In this implementation those concerns are split:
- legacy call compatibility remains inside
XcodeLogger - build/scheme/configuration policy belongs to the consuming app
This implementation replaces that with:
- Swift Package Manager distribution
- structured
LoggerandLoggerConfigurationAPIs - sink-based output routing for
OSLog, stdout, and custom debug capture - app-owned compile-time build policies
The practical outcome is better:
- the library no longer needs to know your app's
Debug,Release,Staging, or custom configuration names - disabling logs for a build becomes an app decision expressed in app code
- when disabled,
Loggerexits before writing to any sink
- iOS 17+
- macOS 14+
- tvOS 17+
- watchOS 10+
- visionOS 1+
Add the package from GitHub and depend on the XcodeLogger product.
dependencies: [
.package(url: "https://github.com/codeFi/XcodeLogger.git", from: "2.2.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "XcodeLogger", package: "XcodeLogger")
]
)
]import XcodeLogger
let logger = Logger(configuration: LoggerConfiguration(
subsystem: "com.example.app",
minimumLevel: .information,
theme: .defaultDark
))
logger.log(
level: .warning,
category: .networking,
message: "Remote service degraded",
metadata: ["region": "eu-central"]
)The new 2.2 features are meant to compose:
- create scoped child loggers once, then reuse them
- keep caller-side logging cheap by making non-interactive sinks asynchronous
- apply redaction globally so every sink receives the same sanitized event
- use per-sink policy to keep each output useful instead of forcing one global threshold
Use child loggers when a subsystem, category, or metadata set repeats across a workflow.
let paymentsLogger = logger
.scoped(subsystem: "com.example.app.payments")
.category("payments")
.scoped(metadata: [
"flow": "checkout",
"screen": "payment-sheet"
])
paymentsLogger.log(
level: .information,
message: "Starting authorization",
metadata: ["requestID": "req-42"]
)
paymentsLogger.log(
level: .error,
message: "Authorization failed",
metadata: ["requestID": "req-42", "attempt": "2"]
)Guidelines:
- prefer child loggers for stable context like feature area, flow, or subsystem
- use per-call metadata for short-lived values like request IDs or retry counts
- later scopes override earlier metadata for the same key
Use whenEnabled(_:) when you already know the final compile-time decision and do not need a provider type.
let configuration = LoggerConfiguration(subsystem: "com.example.app")
.whenEnabled({
#if DEBUG
true
#else
false
#endif
}())Redaction runs before any sink renders output.
let configuration = LoggerConfiguration(
subsystem: "com.example.app",
metadataRedactionRules: [
LoggerMetadataRedactionRule(key: "token"),
LoggerMetadataRedactionRule(key: "email", replacement: "<redacted-email>")
],
messageRedactors: [
LoggerMessageRedactor { message in
message.replacingOccurrences(of: "4111 1111 1111 1111", with: "<card>")
}
]
)Guidelines:
- redact by metadata key for stable fields like
token,email, orsessionID - use message redactors for free-form strings coming from APIs or user input
- treat redaction as global policy, not sink-specific formatting
Different sinks usually need different noise levels.
let consoleSink = DebugConsoleSink(
policy: LoggerSinkPolicy(
minimumLevel: .information,
categoryRules: [
LoggerCategoryRule(pattern: "^network", mode: .allow),
LoggerCategoryRule(pattern: "verbose", mode: .deny)
]
)
)
let fileSink = FileSink(
fileURL: URL(fileURLWithPath: "/tmp/app.log"),
maximumFileSizeInBytes: 512_000,
maximumArchiveCount: 3,
policy: LoggerSinkPolicy(
minimumLevel: .simple,
allowedLevelsByFile: [
"PAYMENTSSERVICE.SWIFT": [.warning, .error]
]
)
)Guidelines:
- use stricter console policies so developers only see actionable output
- keep file sinks broader when they serve post-failure diagnosis
- invalid regex rules are ignored, so validate patterns in tests if they matter
Use sink policy to suppress very noisy categories without disabling them globally.
let sink = DebugConsoleSink(
policy: LoggerSinkPolicy(
samplingRules: [
LoggerSamplingRule(category: .debug, probability: 0.2)
],
rateLimitRules: [
LoggerRateLimitRule(category: .networking, maximumEvents: 20, window: 60)
]
)
)Guidelines:
- use sampling for high-volume debug traces where representative coverage is enough
- use rate limiting for bursty operational categories like networking or retries
- suppressed events are dropped silently in 2.2, so avoid aggressive policies for critical categories
Async delivery is opt-in per sink. It is the default choice for sinks that touch the filesystem.
let fileSink = FileSink(
fileURL: URL(fileURLWithPath: "/var/tmp/example.log"),
maximumFileSizeInBytes: 1_048_576,
maximumArchiveCount: 5,
append: true,
deliveryMode: .asynchronous(batchSize: 16)
)
let logger = Logger(configuration: LoggerConfiguration(
subsystem: "com.example.app",
sinks: [
OSLogSink(subsystem: "com.example.app"),
fileSink
]
))Guidelines:
- keep
OSLogSinksynchronous for immediate system integration - prefer async delivery for file or custom sinks that may block
- start with small batch sizes like
8or16unless you have measured a need for more - rotation archives use stable numbered suffixes such as
example.1.log
If you want no log output for some builds, define that in your app target, not in XcodeLogger.
Important distinction:
XcodeLoggerstill knows how legacy calls map into modern categories- your app decides whether those categories should emit anything in
Debug,Release,Staging, or any custom build
Built-in legacy compatibility mapping:
XLog->defaultDLog->debugDVLog->developmentDDLog->debug-developmentNLog->networking
Create an app-owned file such as AppLogBuildConfiguration.swift:
import XcodeLogger
enum AppLogBuildConfiguration: LoggerBuildConfigurationProviding {
static var isLoggingEnabled: Bool = {
#if DEBUG
true
#elseif STAGING
true
#else
false
#endif
}
}Then apply it when constructing the logger configuration:
import XcodeLogger
let configuration = LoggerConfiguration(
subsystem: "com.example.app",
minimumLevel: .information,
sinks: [
OSLogSink(subsystem: "com.example.app"),
DebugConsoleSink()
]
).applyingBuildConfiguration(AppLogBuildConfiguration.self)
let logger = Logger(configuration: configuration)Why this is the recommended approach:
- the compile-time conditions stay in the app that owns the build settings
XcodeLoggerstays generic and reusable- disabling logs is explicit and easy to audit
- the logger performs a hard early exit when
isEnabled == false
If you do not need a provider type, you can pass the resolved value directly:
let configuration = LoggerConfiguration(
subsystem: "com.example.app",
isEnabled: false
)LoggerLoggerConfigurationLoggerBuildConfigurationProvidingLoggerLevelLoggerCategoryLoggerFormattingLoggerThemeLogEventLogSourceLoggerSinkOSLogSinkDebugConsoleSinkStdoutSinkXcodeLogger
Use this when you want the best integration with Xcode and Console.app.
let logger = Logger(configuration: LoggerConfiguration(
subsystem: "com.example.app",
sinks: [
OSLogSink(subsystem: "com.example.app")
]
))Use this for terminal and CLI output.
let logger = Logger(configuration: LoggerConfiguration(
subsystem: "com.example.cli",
theme: .dracula,
sinks: [
StdoutSink()
]
))Behavior:
- ANSI colors are enabled automatically in supported terminals
- ANSI is suppressed under Xcode to avoid escape-sequence noise
Use this when you want the fully rendered line for a custom debug UI or local capture buffer.
final class LogBuffer: @unchecked Sendable {
private let lock = NSLock()
private(set) var lines: [String] = []
func append(_ line: String) {
lock.lock()
lines.append(line)
lock.unlock()
}
}
let buffer = LogBuffer()
let logger = Logger(configuration: LoggerConfiguration(
subsystem: "com.example.debug-panel",
theme: .dracula,
sinks: [
DebugConsoleSink(supportsANSIColors: false) { line in
buffer.append(line)
}
]
))Use this for persistent local capture with size-based rotation.
let logger = Logger(configuration: LoggerConfiguration(
subsystem: "com.example.app",
sinks: [
FileSink(
fileURL: URL(fileURLWithPath: "/tmp/example.log"),
maximumFileSizeInBytes: 512_000,
maximumArchiveCount: 3
)
]
))Behavior:
- writes append by default
- file delivery is intended to be asynchronous
- archives rotate to numbered siblings like
example.1.log,example.2.log
Use this in your own tests instead of building an ad hoc sink for every suite.
let sink = TestSink()
let logger = Logger(configuration: LoggerConfiguration(
subsystem: "com.example.tests",
sinks: [sink]
))
logger.log(level: .warning, category: .networking, message: "captured")
XCTAssertEqual(sink.events.first?.category, .networking)
XCTAssertTrue(sink.renderedMessages.first?.contains("captured") == true).simple.simpleNoHeader.information.important.warning.error
Built-in categories:
defaultdebugdevelopmentdebug-developmentnetworking
Custom categories:
let payments = LoggerCategory(rawValue: "payments")LoggerConfiguration controls:
- hard enable/disable behavior through
isEnabled - global minimum level
- per-category minimum levels
- enabled category filtering
- global allowed-level overrides
- file-based allowed-level overrides
- theme selection
- header formatting
- timestamp formatting
- line separators
- sink selection
let configuration = LoggerConfiguration(
subsystem: "com.example.app",
enabledCategories: [.debug, .networking],
minimumLevel: .information,
categoryLevels: [
.debug: .simple,
.networking: .warning
],
theme: .defaultDark
)var configuration = LoggerConfiguration(subsystem: "com.example.app")
configuration.allowedLevelsByFile["PAYMENTSSERVICE.SWIFT"] = [.warning, .error]let configuration = LoggerConfiguration(
subsystem: "com.example.app",
formatting: LoggerFormatting(
timestampFormat: "HH:mm:ss.SSS",
headerTokens: [
.literal("["),
.label,
.literal("] "),
.timestamp,
.literal(" "),
.file,
.literal(":"),
.line,
.literal(" "),
.function
],
lineSeparatorAfterHeader: " ",
lineSeparatorAfterMessage: "\n"
)
)LoggerConfiguration.applyingEnvironment(_:) currently supports:
XCODELOGGER_LEVELXCODELOGGER_CATEGORIESXCODELOGGER_ANSI
These are runtime overrides. Build-configuration enablement should still live in app code via LoggerBuildConfigurationProviding or isEnabled.
The Objective-C macro shim lives in Compatibility/XcodeLogger.h.
Compatibility calls route into the modern logger by category and level.
Built-in compatibility mapping:
XLog->defaultDLog->debugDVLog->developmentDDLog->debug-developmentNLog->networking
That mapping is part of the library's compatibility layer and does not need per-app customization.
The old scheme-name registration methods still exist for source compatibility, but they are intentionally inert in the Swift implementation because build behavior now belongs to the consuming app.
Example:
import XcodeLogger
XcodeLogger.shared.emitCompatibilityLog(
type: .development,
level: .information,
file: "LegacyFile.m",
function: "-[LegacyObject run]",
line: 42,
message: "Legacy compatibility output"
)- macOS app:
Examples/XcodeLoggerMacDemo.xcodeproj - iOS app:
Examples/XcodeLoggeriOSDemo.xcodeproj - terminal demo sources:
Examples/XcodeLoggerTerminalDemo
Run the SwiftPM terminal demo:
swift run XcodeLoggerTerminalDemoRun the package test suite:
swift test