Articles Projects About Subscribe

Module Tracking Tips for Faster Swift Debugging

Teams burn days chasing crashes that start with “thread 0” and end with “who owns this?” The stack trace points at your app, but the culprit module is unclear, the dSYM doesn’t match, and you’re stepping through Foundation instead of your code. That failure mode is avoidable. Tag signals with their module, keep symbols aligned, and scope the debugger to where the bug really lives.

All code in this article targets iOS 26+ and Swift 6.2 unless noted otherwise.

Why This Matters

As apps adopt Swift Package Manager and split features across many modules, mis-routed triage gets expensive. CI won’t catch every behavioral regression at module boundaries, and monolith-era habits—print, global breakpoints, and lax symbol handling—turn a one-line fix into a multi-day incident. Apple’s unified logging stack (Console, Logger, OSSignposter) and recent improvements in how Swift tracks modules in debug info help tighten the loop. With per-module logs, scoped breakpoints, and matching dSYMs, you can often get from “it crashed” to a named module and file much faster.

The fastest crash fix I’ve shipped started with the right module name in the log line, not a prettier stack trace.

1. Make Modules Observable In Logs And Traces

Prefer Logger, OSSignposter, And #fileID Per Module

The anti-pattern is print(“fetching user”) sprinkled across code. You can’t reliably filter it by module in Console, and it doesn’t show up in Instruments.

Adopt a tiny logging wrapper per module. Use stable subsystem/category pairs, include the module name in category, and avoid instantiating Logger directly in feature code. Gate verbose logs behind a dynamic flag, and use privacy to avoid leaking sensitive data.

import OSLog
import Foundation

public enum PaymentsLog {
    static let logger = Logger(subsystem: "com.yourco.app", category: "Module.Payments")
    static let signposter = OSSignposter(subsystem: "com.yourco.app", category: "Module.Payments")
}

public struct PaymentFetcher {
    private let session: URLSession
    public init(session: URLSession = .shared) { self.session = session }

    public func fetch(invoiceID: String) async throws -> Data {
        let state = PaymentsLog.signposter.beginInterval("FetchInvoice", "\(invoiceID, privacy: .private)")
        PaymentsLog.logger.info("Request started in \(#fileID, privacy: .public)")

        defer {
            PaymentsLog.signposter.endInterval("FetchInvoice", state)
        }

        guard let url = URL(string: "https://api.yourco.com/invoice/\(invoiceID)") else {
            throw URLError(.badURL)
        }
        do {
            let (data, _) = try await session.data(from: url)
            PaymentsLog.logger.info("Request finished: bytes=\(data.count, format: .decimal)")
            return data
        } catch {
            PaymentsLog.logger.error("Request failed: \(error.localizedDescription, privacy: .public)")
            throw error
        }
    }
}```

When multiple teams own code paths, use per-module wrappers. If your app is small and ownership is centralized, a single shared wrapper can be sufficient. Reusing the same category string across modules makes targeted filtering harder.

Operationally, enforce the wrapper with a lint that forbids raw Logger initialization, and validate in code review that category includes the module name.

## 2. Fix Symbolication And Scope The Debugger To The Right Module
### Match dSYMs And Use Xcode’s Module Filter
The anti-pattern is global regex breakpoints and “we’ll upload dSYMs later.” That yields unsymbolicated frames in production and time wasted stepping through the wrong module.

Archive and upload the exact dSYM for each build artifact, and scope breakpoints by module instead of only by symbol names. Recent Swift releases improved how the debugger finds the correct Swift module via debug info, which typically reduces “could not find type” issues—if your symbols match the shipped binary.

```swift
// Place a non-inlined symbol in each module to validate symbolication during CI.
public enum PaymentsSymbolCheck {
    @inline(never)
    public static func anchor() -> String { "PaymentsSymbolAnchor" }
}

// Example CI-side validation (pseudo-invoked via a small test harness):
// 1) Load the app binary
// 2) Ensure the symbol store contains the UUID(s) from the app’s slices
// 3) Resolve `PaymentsSymbolCheck.anchor()` into a symbol name for the current slice

Before: a global breakpoint on a mangled method name that also exists in Common. After: a breakpoint with “Module = Payments” so execution stops only in the owning module.

Operationally, ensure post-processing doesn’t strip debug sections needed by the debugger. If you re-sign or re-package artifacts after export, verify per-architecture UUIDs and fail the job if the symbol store is missing any slice.

3. Encode Module Contracts, Not Just UI Paths

Use XCTest For Invariants And Keep Them Cheap

UI tests are too late and too slow to protect module boundaries. Instead, add lightweight tests that encode module contracts—IDs, formats, error mapping, and thread/actor expectations.

import XCTest
import Foundation

enum Payments {
    struct InvoiceID {
        let rawValue: String
        init(_ raw: String) { self.rawValue = raw }
        var isValid: Bool { UUID(uuidString: rawValue) != nil }
    }
}

actor PaymentFetcher {
    func fetch(invoiceID: String) async throws -> Data {
        Data(invoiceID.utf8)
    }
}

final class PaymentsInvariantsTests: XCTestCase {
    func testInvoiceIDFormat() {
        let valid = "C5D9B4F6-2EB5-4F95-8577-1B43A74B504A"
        let invalid = "abc123"
        XCTAssertTrue(Payments.InvoiceID(valid).isValid)
        XCTAssertFalse(Payments.InvoiceID(invalid).isValid)
    }

    func testFetcherIsActorSafe() async throws {
        let fetcher = PaymentFetcher()
        async let a: Data = try fetcher.fetch(invoiceID: "id-a")
        async let b: Data = try fetcher.fetch(invoiceID: "id-b")
        _ = try await (a, b)
    }
}```

When a module serves multiple callers, add contract tests. If a cross-module flow cannot be isolated, add heavier end-to-end tests selectively.

Operationally, keep contract tests in the same package as the module with @testable import, and ensure your CI runs them for every PR touching that module.

## 4. Make Incidents Module-Aware End To End
### Route Logs, Crashes, And On-Call Ownership By Module
The anti-pattern is Slack ping storms and shared “triage rooms” where everyone tails Console. Replace that with module tags in logs, module-aware crash grouping, and a clear owner map.

Add a static owner map next to each module, and use it to route alerts automatically. Require at least one OSSignposter interval around each high-traffic path so performance timelines line up with ownership.

```swift
public enum ModuleOwnership {
    static let owners: [String: String] = [
        "Module.Payments": "payments-oncall",
        "Module.ShareSheet": "sharing-oncall"
    ]
}

func routeHighSeverityLog(category: String, message: String) {
    let channel = ModuleOwnership.owners[category] ?? "ios-core-oncall"
    // Send to your alerting system; include `category` and `message`
}

When you want reviewable history, use hard-coded ownership maps in the repo. When teams change often, use remote-config maps and back them with a periodic consistency check. Keep owner data fresh to avoid misrouted alerts.

Operationally, support a remote switch to raise or lower log levels for a single module and time-box it to avoid unnecessary log pressure in production.

5. Migrate Breakpoints And Expression Evaluation To Be Module-First

Use Xcode’s “Module” Filter And Guard LLDB Context

When expression evaluation fails with “could not find type,” it’s often the wrong module context. Improved module tracking can help, but you still need to steer LLDB.

  • In Xcode, set a Swift breakpoint with “Module = Payments,” not just a symbol regex.
  • In LLDB, prefer explicit language and imports when needed: expr -l Swift – import Payments.
  • Keep utility code minimal. If Common swallows logic, module filters can hide the real locus.
import os

public struct PaymentsEntry {
    @inline(never)
    public static func triageGate<T>(_ label: String, _ work: () throws -> T) rethrows -> T {
        PaymentsLog.logger.info("TriageGate: \(label, privacy: .public)")
        return try work()
    }
}

enum PaymentsLog {
    static let logger = Logger(subsystem: "com.example.payments", category: "triage")
}```

Before: stepping into every Common helper due to a global breakpoint. After: a single triage gate in Payments where you can stop reliably during incident response.

Operationally, remove global regex breakpoints from shared workspace configs and avoid adding new ones.

## Tradeoffs And Pitfalls
- Logging overhead: Logger is efficient, but interpolating large payloads in hot paths can still be costly. Prefer logging identifiers and sample payloads on demand.
- Signpost sprawl: Too many OSSignposter intervals can drown the timeline and add overhead. Focus on critical paths and use counters for simple rates. Nest only when you must prove contention.
- dSYM mismatch: A top failure mode. Automate a UUID check per slice during release; if the app UUIDs aren’t in your symbol store, fail the job.
- Module filter blind spots: If heavy logic moves to a Common module, filtering on Payments can miss the root cause. Keep utilities narrow and push product logic back to feature modules.
- Expression evaluator quirks: Custom build steps that strip debug sections can undermine improved module tracking. Align your link and strip stages with current Swift debug-info expectations.

## Validation & Observability
- XCTest async expectations: budget your async paths and fail fast for regressions.
- Instruments Time Profiler: verify that your OSSignposter intervals bracket real work. If the interval shows idle time, you instrumented the wrong layer.
- OSSignposter across async: carry a single SignpostID across tasks to correlate retries and handoffs; otherwise, timelines fragment.
- MetricKit crash diagnostics: ingest MXCrashDiagnostic records, symbolize with your archived dSYM, and attach an owning module based on the top frame. Alert on per-module crash rate, not just global numbers.
- Structured logging via Logger: use .info for success and .error for failures; mark sensitive values .private. Add a unit test that scans sources for forbidden logging calls.
- Rollout gates: tie verbose logging and new signposts to a remote-config flag. Default off, enable for a minority slice, and auto-expire after the incident window.

## Practical Checklist
- [ ] Create a per-module wrapper with Logger and OSSignposter; include the module name in category.
- [ ] Replace print with Logger; use #fileID to surface source context; always redact with .private as needed.
- [ ] Add at least one signposted interval around each module’s highest-traffic path.
- [ ] Lint to forbid direct Logger instantiation outside the module wrapper.
- [ ] Use Xcode breakpoints scoped by “Module = <YourModule>”; remove global regex breakpoints.
- [ ] Archive and upload a matching dSYM per build; verify UUIDs for all slices before release.
- [ ] Add XCTest contract tests for module invariants and actor/thread expectations.
- [ ] Symbolicate MetricKit crashes and tag incidents with the owning module after resolution.
- [ ] Add remote-config switches to raise or lower log levels per module with an expiry time.
- [ ] Review Console logs and Instruments timelines for each module before merging major changes.

## Closing Takeaway
Module tracking is not an observability luxury; it’s how you keep modular Swift apps debuggable after rollout. Per-module logs and signposts tell you where to look. Matching dSYMs and module-scoped breakpoints let you act quickly. Contract tests, performance timelines, and MetricKit close the loop in production. The winning move is to remove guesswork from triage—make your modules speak for themselves.

## Swift/SwiftUI Code Example

```swift
import Foundation
import OSLog

enum FeatureError: Error { case invalidResponse }

enum ModuleTracking {
    static let subsystem = Bundle.main.bundleIdentifier ?? "App"
    static let category = "Feature"
    static let log = Logger(subsystem: subsystem, category: category)
    static let signposter = OSSignposter(subsystem: subsystem, category: category)

    static func task<T>(_ name: StaticString, _ operation: () throws -> T) rethrows -> T {
        let id = signposter.makeSignpostID()
        let state = signposter.beginInterval(name, id: id)
        log.debug("\(name) start — file=\(#fileID) fn=\(#function)")
        defer {
            signposter.endInterval(name, state)
            log.debug("\(name) end")
        }
        return try operation()
    }

    static func task<T>(_ name: StaticString, _ operation: () async throws -> T) async rethrows -> T {
        let id = signposter.makeSignpostID()
        let state = signposter.beginInterval(name, id: id)
        log.debug("\(name) start — file=\(#fileID) fn=\(#function)")
        defer {
            signposter.endInterval(name, state)
            log.debug("\(name) end")
        }
        return try await operation()
    }
}

struct FeatureRepository {
    func loadUser(id: String) async throws -> String {
        try await ModuleTracking.task("FeatureRepository.loadUser") {
            ModuleTracking.log.info("Fetching user id=\(id)")
            try await Task.sleep(nanoseconds: 10_000_000)
            if id.isEmpty {
                ModuleTracking.log.fault("Empty user id — file=\(#fileID) fn=\(#function) line=\(#line)")
                throw FeatureError.invalidResponse
            }
            return "user:\(id)"
        }
    }
}

func demoUsage() async {
    do { _ = try await FeatureRepository().loadUser(id: "") }
    catch { ModuleTracking.log.error("Triage hint — error=\(String(describing: error))") }
}

References