Add @AutoLog to your Swift types to trace calls, results, errors, timing, and
supported branch decisions without writing a log statement at every step.
Open source under the MIT license.
- Before and After Comparison: log output Β· service code Β· SwiftUI
- Quick start β What gets logged
- Choose what to log: types and functions Β· manual messages
- SwiftUI interactions and tasks
- Customize: global settings Β· individual macro settings Β· failures-only builds Β· omission Β· deduplication
- Special features and cases: early exits Β· guard evaluation test Β· failure context
- Existing loggers and custom sinks
- Installation and compatibility: Swift Package Manager Β· macro trust and CI Β· migration
- Performance Β· Documentation and tests
You tap Reload profile, and the refresh fails because the device is offline.
Before β messages you write and maintain:
[UI] Reload profile tapped.
[Profile] Refresh requested for profile 42.
[Profile] Loading profile 42.
[Cache] Looking for a saved profile.
[Cache] No cached profile found.
[Network] Requesting profile from the server.
[Network] Request failed: no internet connection.
[Profile] Could not refresh the profile.
After β an execution trace:
ππΌ-π§΅A1B - ππΌ Reload profile [ProfileView:9]
ππΌ-π§΅A1B β main β β --> π΅ ProfileViewModel.reload(id: 42)
ππΌ-π§΅A1B β main β β ---> π΅ ProfileService.load(id: 42)
ππΌ-π§΅A1B β main β β ----> π΅ ProfileCache.lookup(id: 42)
ππΌ-π§΅A1B β main β 1.6Β΅s β ----> π’ ProfileCache.lookup(id: 42) β nil
ππΌ-π§΅A1B β main β β ----> π΅ ProfileAPI.fetch(id: 42)
ππΌ-π§΅A1B β main β 14.1Β΅s β ----> π΄ ProfileAPI.fetch(id: 42) β offline [NetworkError]
ππΌ-π§΅A1B β main β 852Β΅s β ---> π΄ ProfileService.load(id: 42) β offline [NetworkError]
ππΌ-π§΅A1B β main β 1.1ms β --> π΄ ProfileViewModel.reload(id: 42) β offline [NetworkError]
The first line identifies the button tap and its source location. The same
ππΌ-π§΅A1B flow badge connects that tap to the view model, cache miss, API
failure, and error propagation. The default logger can also attach
recent context to failures.
These illustrative logs extend the offline-request example from the
website walkthrough
with a Button.log origin. The compact layout shows arguments, interaction
origins, thread, timing, and depth. Badges and timings vary; repeated
error-context blocks are omitted here.
The same change removes logging boilerplate from your source.
Before:
struct ProfileService {
let hasAccess: Bool
func load(id: Int) throws -> String? {
print("Loading profile \(id).")
guard hasAccess else {
print("Skipping profile: access denied.")
return nil
}
do {
let profile = try ProfileAPI().fetch(id: id)
print("Loaded profile \(id).")
return profile
} catch {
print("Failed to load profile: \(error)")
throw error
}
}
}After:
import AutoLog
@AutoLog
struct ProfileService {
let hasAccess: Bool
func load(id: Int) throws -> String? {
guard hasAccess else {
return nil
}
return try ProfileAPI().fetch(id: id)
}
}ProfileAPI is an app-defined dependency. Annotate each layer you want in the
trace; this smaller source example leaves out the caller and cache above.
Replace the manual tap message with Button.log. Both views call the same
reload action, such as an annotated view model's reload method.
Before:
import SwiftUI
struct ProfileView: View {
let reload: () -> Void
var body: some View {
Button("Reload profile") {
print("[UI] Reload profile tapped.")
reload()
}
}
}After:
import AutoLogSwiftUI
import SwiftUI
struct ProfileView: View {
let reload: () -> Void
var body: some View {
Button.log("Reload profile") {
reload()
}
}
}The tap now carries its source location and flow badge into downstream annotated calls. A compact trace can look like this:
ππΌ-π§΅A1B - ππΌ Reload profile [ProfileView:9]
ππΌ-π§΅A1B β main β β -> π΅ ProfileViewModel.reload()
ππΌ-π§΅A1B β bg β 221ms β --> π΄ ProfileAPI.fetch() β offline [NetworkError]
The output is illustrative; badges, locations, timing, and the downstream calls depend on the action you provide. See SwiftUI auto-logging for async actions, bindings, and lifecycle helpers.
Requires Swift 6.2 / Xcode 26 or newer. Supports iOS 17+, macOS 15+, and watchOS 11+.
In Xcode, open Project β Package Dependencies β + and enter:
https://github.com/anciltech/Swift-AutoLog.git
Choose a version starting at 0.11.0 and add the AutoLog product to your
target. Also add AutoLogSwiftUI if you want helpers such as Button.log.
Using a package manifest? See Swift Package Manager.
Call this during app startup, such as in your SwiftUI App initializer:
import AutoLog
AutoLog.start()This small example has no external dependencies:
import AutoLog
@AutoLog
struct ProfileService {
let hasAccess: Bool
func load(id: Int) -> String? {
guard hasAccess else {
return nil
}
return "Profile \(id)"
}
}
let profile = ProfileService(hasAccess: false).load(id: 42)You get the normal nil result plus metadata identifying the failed access
guard. Start with view models, services, use cases, repositories, and data
sources. For a single function, use @Log.
On the first build, accept Xcode's Trust & Enable prompt for the macro. For headless builds, see macro trust and CI.
Inside annotated code, AutoLog records the following cases. Function and branch metadata share the function's completion event; interaction and catch-site messages can emit their own events. Customization controls which generated paths and outputs are enabled.
| Case | What you get |
|---|---|
| Function or method entry | A start event with the declaration, source location, and captured arguments. |
Normal return, including Void, nil, or false |
A successful completion with the result and optional duration. A normal return is successful even when the business outcome is an early exit. |
throw, including an error from a downstream call |
A failure completion with the thrown error; annotated callers record the error as it propagates. |
guard β¦ else with return or throw |
Early-exit metadata identifying the failed guard condition. Supported composite boolean guards record evaluated terms while preserving short-circuiting. |
if, else if, and else |
The branch taken, including branches that continue normally. A return or throw in the branch also records the early exit. |
switch case and default |
The selected case/default path, plus early-exit metadata when it returns or throws. |
do / catch |
A warning event when a catch runs, the caught error when a binding is available, and a handled-error count on the parent event. Recovery can still end in success; rethrowing still ends in failure. |
Supported branches inside for, for await, while, and repeat bodies |
The nested branch/early-exit/catch details. Loop iterations and loop conditions do not get their own automatic events. |
async and async throws functions |
The same entry, completion, result/error, timing, and inherited interaction context across await. An await expression is not a separate automatic event. |
| Explicitly annotated initializers | @Log records completion, a failable initializer's nil exit, or a thrown error. @AutoLog does not automatically annotate initializers. |
Button.log, Binding.log, and explicit interaction sources |
The interaction or value change, its origin, and the context carried into downstream annotated calls. |
.onAppearLog, .taskLog, and .taskLog(id:) |
Explicitly logged view lifecycle actions and tasks. |
AutoLog.logTask |
An explicitly traced task operation; use it for task-group children that need their own operation event. |
Manual helpers such as AutoLog.note and AutoLog.warning |
An inline event for a domain milestone you choose to record. |
Each function event can also carry tags, routing information, branch decisions, handled-error details, and its originating interaction. The formatter chooses which of these fields to show.
This service recovers from an offline error. AutoLog records the thrown fetch
failure, the warning at the catch site, and the successful cached result from
load:
import AutoLog
@AutoLog
struct OfflineProfileService {
enum Failure: Error { case offline }
func load() -> String {
do {
return try fetch()
} catch {
return "Cached profile"
}
}
func fetch() throws -> String {
throw Failure.offline
}
}do itself is not a separate event. Assignments, ternary expressions, break,
continue, and defer blocks are not independent automatic logging cases.
Use manual messages at a stream's yield site to make individual values visible.
Computed properties are not automatically logged, and type-wide logging skips
certain framework callbacks such as representable lifecycle and WebKit delegate
methods. Use explicit function logging or manual messages where appropriate.
@AutoLog instruments methods in a type or extension. Use @Log to select one
function or an individual initializer:
struct ProfileCache {
@Log
func lookup(id: Int) -> String? {
nil
}
}Events can include the declaration, source location, arguments, result, thrown error, duration, tags, and supported branch metadata. Macros generate Swift at compile time; inspect their expansion in Xcode.
Keep manual messages for domain milestones inside a function. The macros already cover function entry, completion, thrown errors, return values, and timing, so you can remove duplicate boundary messages.
AutoLog.note("Cache hit")
AutoLog.update("Starting profile sync")
AutoLog.warning("Using a stale profile")
AutoLog.success("Profile sync finished")
AutoLog.failure("Profile sync failed")AutoLog.manual(message) and AutoLog.emit(level, message) are also available.
Add the AutoLogSwiftUI product and import it alongside SwiftUI. A logged
button supplies an interaction source to downstream annotated calls:
import AutoLogSwiftUI
import SwiftUI
Button.log("Reload profile") {
await viewModel.reload()
}Synchronous actions use the same form. Each interaction gets a source and flow badge, so overlapping operations remain distinguishable:
ππΌ-π§΅A1B - ππΌ Reload profile [ProfileView:42]
ππΌ-π§΅A1B β main β β -> π΅ ProfileViewModel.reload()
ππΌ-π§΅A1B β bg β 221ms β --> π΄ ProfileAPI.fetch() β offline [NetworkError]
Bindings can carry the same context from controls:
Toggle("Dark Mode", isOn: $viewModel.isDarkMode.log(.toggle("Dark Mode")))Other sources include .textField, .keyboard, .gesture, .api, .system,
and .timer. Outside SwiftUI, establish a source explicitly:
AutoLogInteraction.button("Save").log {
viewModel.saveTapped()
}.onAppearLog, .taskLog, and .taskLog(id:) cover view lifecycle work.
For child operations inside a task group, wrap each child in
AutoLog.logTask("Child operation") { ... } to emit a traced operation with
inherited interaction context. Detached work needs deliberate context handling.
See Usage for the full interaction and lifecycle examples.
Set the default behavior once with AutoLog.start(configuration:). Each output
can then select its own events, route, privacy, timing, and format:
import AutoLog
AutoLog.start(
configuration: AutoLogConfiguration(
deduplicateWithin: .seconds(1),
outputs: [
AutoLogOutput(
name: "Console results",
route: .console,
isEnabled: true,
eventTypes: [.success, .failure, .warning],
typeNameFilters: ["ViewModel", "Service"],
measureTiming: true,
errorContextLimit: 5,
parameterPrivacy: .omit
)
]
)
)| Setting | Controls |
|---|---|
outputs |
One or more destination profiles. |
Output isEnabled and route |
Whether an output is active and which sink receives it, such as .console, .firebase, or .splunk. |
Output eventTypes |
.interaction, .start, .success, .failure, and .warning events. |
Output typeNameFilters |
Case-insensitive type-name matching. An empty list accepts all types. |
measureTimingDefault, output measureTiming, and timing-depth settings |
Whether and where timing is captured or displayed. |
deduplicateWithin |
The process-wide window for suppressing repeated identical events. |
Output parameterPrivacy |
.include, .omit, or .allowList(...) for argument values. Console defaults to include; other routes default to omit. |
Output errorContextLimit and failureContextTriggerLimit |
How much recent context is attached to a failure. |
logFormat and output logFormat |
Which fields and columns the compact formatter shows. |
normalizedTiming |
Whether durations use normalized fixed-width seconds. |
Use AutoLog.start(logger:configuration:) for a custom logger, or
AutoLog.start(configuration:sinks:) to add backend sinks. Update configuration
while the app runs with AutoLog.updateConfiguration(...). Each function keeps
the snapshot captured at its entry; later calls pick up the new settings.
The compact formatter bounds object and collection previews. Adjust
CompactFormatter(collectionPreviewLimit:) or
CompactFormatter(objectPreviewLineLimit:) to change those limits.
AutoLogSettingsUI provides an optional settings interface for development.
See Usage for persistence,
multiple outputs, timing-depth filters, and scoped runtime overrides.
When you supply outputs, each profile's timing and layout settings apply to
that destination. Configuration-level timing and layout defaults configure the
default console profile when outputs is empty.
Use @AutoLog for a type-wide policy and @Log for an individual function.
An explicit @Log on a method takes precedence over the surrounding
@AutoLog policy. Trait macros adjust individual methods inside a logged type:
| Setting | Example | Effect |
|---|---|---|
| Generated event paths | @AutoLog(emit: [.failures, .warnings]) or @Log(emit: .failures) |
Selects which logging paths exist in the compiled code. |
| Timing | @AutoLog(measure: false) or @Log(measure: false) |
Removes timing work for that type or function. measure: true explicitly requests timing. |
| Logger | @AutoLog(using: logger) or @Log(using: logger) |
Selects a logger for that scope. |
| Level | @Level("debug") or @Log(level: "debug") |
Sets the event's logging level. |
| Tags and routes | @Tag("auth", .console) |
Adds descriptive tags and restricts routes when route tags are supplied. |
| Skip a function | @Omit |
Excludes it from type-wide logging. |
| Omit captured values | @Omit("token"), @Omit(.parameters), or @Omit(.result) |
Excludes a named parameter, all arguments, or the result from capture. |
| Suppress matching events | @Omit(.result, containing: "false") or @Omit(.error, upToCount: 1) |
Suppresses matching result/error events, optionally for a limited number of occurrences. |
| Success-result filter | @When(true) |
Emits the success completion only when the result matches; thrown failures still emit. |
| Per-function deduplication | @Deduplicate(.seconds(1)) |
Suppresses repeated identical events for that function within the window. |
For example, keep a session operation visible without capturing its token:
import AutoLog
@AutoLog
struct SessionService {
@Omit("token")
@Tag("auth", .console)
@Level("debug")
func refresh(token: String) -> Bool {
!token.isEmpty
}
}Runtime settings filter the events generated by the macro; they cannot enable a
path removed by emit:. Prefer global settings for shared policy and local
settings for a function that needs different behavior.
Runtime filtering selects which events reach an output. emit: selects which
logging paths the macro generates. For a service that only needs thrown failures:
import AutoLog
import Foundation
@AutoLog(emit: .failures, measure: false)
struct DocumentService {
func read(_ url: URL) throws -> String {
try String(contentsOf: url, encoding: .utf8)
}
}Available paths are .starts, .successes, .failures, .warnings,
.completions, and .all. Combine them with an array, such as
emit: [.failures, .warnings]. The same options work with @Log.
A guard that returns nil or false is still a successful return, so a
failures-only policy does not emit that completion. Keep successes when those
early exits matter to your diagnosis.
For build profiles derived from output settings, use
configuration.derivedBuildConfiguration; route and type-name filters remain
runtime-only. See Existing Logging Integration
for production-profile guidance.
Use @Omit to exclude a method from type-wide logging, @Omit(.parameters) to
exclude captured arguments, or @Omit(.result) to exclude a result. Output
profiles also support .include, .omit, and .allowList(...) parameter
privacy policies. Console output includes parameters by default; non-console
routes omit them by default. Choose a policy before capturing credentials or
other private values.
Conditional rules suppress matching result/error events:
@AutoLog
struct SelectionService {
let selectedIDs: Set<Int>
@Omit(.result, containing: "false")
func isSelected(id: Int) -> Bool {
selectedIDs.contains(id)
}
}For expected errors, use @Omit(.error, containing: ["nextAuthStep"], upToCount: 1).
Multiple containing: tokens use AND semantics after normalization.
See Customizing Macro Behavior
for the complete omission and tag options.
Suppress repeated identical events in a noisy function:
@AutoLog
struct InteractionManager {
let hoveredFrameID: Int
@Deduplicate(.seconds(1))
func hover(frameID: Int) -> Bool {
frameID == hoveredFrameID
}
}Set AutoLogConfiguration(deduplicateWithin: .seconds(1)) for a process-wide
window instead. Global deduplication also applies to manual events.
A normal result can still explain why work stopped:
main β 1.9Β΅s β -> π’ ProfileService.load(id: 42) β nil
β°β βοΈ !hasAccess
Green means a normal return. The recorded guard condition explains the nil.
Supported early exits from guard, if, else if, else, and switch branches
can attach this metadata. Conditional branches that continue can record the
taken path too.
Add a domain explanation when the condition alone is not enough:
guard hasAccess else {
AutoLog.earlyExit(.expected, "access denied by policy")
return nil
}The explicit explanation replaces the default early-exit record for that branch.
AutoLog.earlyExit(.optimize, "cache hit") and AutoLog.earlyExit() are also
supported.
Recording a composite guard must preserve short-circuiting and must not evaluate
its terms again to explain the branch. This example compares the same guard with
and without @AutoLog, using a counter to make extra evaluations observable:
import AutoLog
struct PlainGuard {
let canLoad: () -> Bool
func load(hasAccess: Bool) -> Bool {
guard hasAccess && canLoad() else {
return false
}
return true
}
}
@AutoLog
struct GuardExample {
let canLoad: () -> Bool
func load(hasAccess: Bool) -> Bool {
guard hasAccess && canLoad() else {
return false
}
return true
}
}
for hasAccess in [false, true] {
for canLoadResult in [false, true] {
var plainCalls = 0
let plain = PlainGuard(canLoad: {
plainCalls += 1
return canLoadResult
})
let expected = plain.load(hasAccess: hasAccess)
var loggedCalls = 0
let example = GuardExample(canLoad: {
loggedCalls += 1
return canLoadResult
})
let actual = example.load(hasAccess: hasAccess)
precondition(actual == expected)
precondition(loggedCalls == plainCalls)
precondition(loggedCalls == (hasAccess ? 1 : 0))
}
}canLoad() is called zero times when access is false and exactly once
when access is true, whether it returns true or false.
The runtime test compares
these results and call counts with output enabled, output disabled
(isEnabled: false), runtime output filtered to .failure, and
@AutoLog(emit: .failures). It also checks the emitted guard metadata and that
disabled/failures-only modes emit no events for these normal returns. Returning
false from a guard is a successful function return, not a thrown failure.
Run the comparison from the package directory:
swift test --filter AutoLogGuardSemanticsTestsThe default AutoLogLogger keeps recent formatted log lines and can attach them
to a thrown-error event. Configure errorContextLimit per output to control how
much lead-up is included, and failureContextTriggerLimit to limit the number
of interaction triggers represented in that trail.
Locally handled catch blocks can emit warning events and add a handled-error
count to the parent event. A recovered function can still finish successfully;
a thrown failure and a handled warning remain distinct outcomes. Include
.warnings in compile-time emission and .warning in runtime output when you
want those catch-site events.
Keep your existing backend and use AutoLog to generate the function events.
Implement AutoLogEventSink for a destination, or AutoLogger for complete
control over delivery. For example:
struct ExistingBackendSink: AutoLogEventSink {
let route: AutoLogRoute = .splunk
func emit(event: AutoLogEvent, configuration: AutoLogConfiguration) {
// Forward the event to your existing backend.
}
}
AutoLog.start(
configuration: AutoLogConfiguration(
outputs: [
AutoLogOutput(
name: "Existing backend",
route: .splunk,
eventTypes: [.failure, .warning],
parameterPrivacy: .omit
)
]
),
sinks: [ExistingBackendSink()]
)Untagged events can reach each enabled output. Add @Tag(.console, .splunk) to a
method to restrict it to those routes, or @Tag(.splunk) for only that backend.
For Apple's Logger, use @OSLogger with @OSLogged on a type. Use @OSLog
for an individual method of a type that provides an OSLogger conformance.
See OSLog,
custom loggers,
and backend sink examples.
Add the source package to your manifest:
.package(url: "https://github.com/anciltech/Swift-AutoLog.git", from: "0.11.0"),Then add the products your target uses:
.target(
name: "MyApp",
dependencies: [
.product(name: "AutoLog", package: "swift-autolog"),
.product(name: "AutoLogSwiftUI", package: "swift-autolog")
]
)AutoLogSwiftUI is optional. Runtime, macros, SwiftUI helpers, settings, and tests
are available as source. No private repository credentials or binary downloads
are required. Use a local path dependency when developing the library; customer
apps should normally use a tagged release.
The AutoLog library supports iOS 17, macOS 15, and watchOS 11 or newer. The
settings UI is development tooling and is not required on watchOS.
AutoLogSwiftUI's .taskLog helpers manage tasks through view lifecycle hooks;
the package includes a watchOS Release-link smoke check for these APIs.
Swift 6.2 is the compiler minimum because async wrappers use
nonisolated(nonsending) to preserve caller actor isolation and support
non-Sendable results. See the production checklist
for integration checks.
Swift macros run as compiler plugins. Xcode requires developers to trust them; the package cannot grant that trust itself. Accept Trust & Enable locally. CI should use pinned, reviewed package versions.
For a trusted headless Xcode build, pass -skipMacroValidation to xcodebuild.
For Xcode Cloud, add this to ci_scripts/ci_post_clone.sh and make the file
executable:
#!/bin/zsh
set -euo pipefail
defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YESThese settings bypass Xcode's interactive macro-trust prompt on that build host. See Macro Trust and CI for the full setup, including build-plugin validation when applicable.
The separate binary distribution repository has been retired. Replace its URL
with https://github.com/anciltech/Swift-AutoLog.git, starting at 0.11.0, and
use the swift-autolog package identity. Keep the AutoLog / AutoLogSwiftUI
products and imports, re-resolve dependencies, and review the new macro revision
for CI trust.
Logging has runtime and build costs. The package includes benchmarks comparing plain functions, annotated functions, tags, and routing profiles. Run them from the package directory:
swift test --filter AutoLogPerformanceTestsUse Instruments Time Profiler to investigate event creation, formatting, and routing costs in your own app. See Measuring Performance for the measurement method, recorded results, and profiling commands.
- Full documentation
- Usage and configuration
- Existing logging integration
- Customization
- Runtime and integration tests
- Macro expansion tests
Run the package suite from this directory with swift test, or the focused
side-effecting guard test
with swift test --filter AutoLogGuardSemanticsTests.