Skip to content

About

Open-source Swift macros and runtime for structured execution, timing, error and SwiftUI interaction logging

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Swift AutoLog

CI

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.

Contents

Before and After Comparison

Log output

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.

Service code

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.

SwiftUI example

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.

Quick start

Requires Swift 6.2 / Xcode 26 or newer. Supports iOS 17+, macOS 15+, and watchOS 11+.

1. Add the package

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.

2. Start logging once

Call this during app startup, such as in your SwiftUI App initializer:

import AutoLog

AutoLog.start()

3. Annotate a type and call it

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.

What gets logged

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.

Example: an error is caught and recovered

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.

Choose what to log

Types and functions

@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.

Manual messages

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.

SwiftUI auto-logging

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.

Customize

Global settings

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.

Individual macro settings

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.

Failures-only production builds

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.

Omit values, methods, or noisy events

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.

Deduplicate repeated events

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.

Special features and cases

Early exits and branch paths

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.

Side-effecting guards preserve evaluation

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 AutoLogGuardSemanticsTests

Failure context and handled errors

The 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.

Existing loggers and custom sinks

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.

Installation

Swift Package Manager

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.

Compatibility

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.

Macro trust and CI

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 YES

These 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.

Migrating from the binary distribution

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.

Measuring performance

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 AutoLogPerformanceTests

Use 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.

Documentation and tests

Run the package suite from this directory with swift test, or the focused side-effecting guard test with swift test --filter AutoLogGuardSemanticsTests.

About

Open-source Swift macros and runtime for structured execution, timing, error and SwiftUI interaction logging

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages