Logging, MetricKit, Crash Reports and Organizer

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Once FieldNotes ships, the debugger is gone. What remains is what the app wrote down and what the system measured: log messages, signposts, daily performance metrics, diagnostic reports for crashes and hangs, and feedback from TestFlight testers. This page sets up each of those channels for FieldNotes and shows where the data arrives, using the 2026 baseline of this section: iOS, iPadOS, macOS, watchOS and visionOS 27, Xcode 27 and Swift 6.4, with a deployment minimum of 26. The 27 releases rebuilt MetricKit around a Swift-first MetricManager, so the page shows it together with the MXMetricManager path that FieldNotes still needs on 26.

Interactive debugging is on Debugging with Xcode and LLDB, and turning measurements into fixes is on Performance and Instruments. Server-side metrics, dashboards and alerting are a separate discipline, covered by the Prometheus & Monitoring section; this page stops where the app hands its data to a server.

Where Field Data Comes From

Each channel has a different path from the device to you. Logs stay on the device unless someone collects them. MetricKit hands reports to the app itself, which forwards them to your server. Crash and performance data from people who share analytics, and every report from TestFlight testers, goes to App Store Connect and appears in the Xcode Organizer.

Figure: From the device to the developer

flowchart LR subgraph Device["Device running FieldNotes"] App["FieldNotes
Logger, OSSignposter, StateReporter"] Sys["System diagnostics
crashes, hangs, launches, energy"] TFApp["TestFlight app
screenshot and crash feedback"] end App -- "log messages and signposts" --> Sys Sys -- "MetricManager (27) or MXMetricManager (26)" --> App App -- "uploads MetricReport JSON" --> Server["Your metrics server
for example Prometheus and Grafana"] Sys -- "people who share analytics, all TestFlight testers" --> ASC["App Store Connect"] TFApp --> ASC ASC --> Org["Xcode Organizer
Insights, Metrics, Crashes, Feedback"] ASC -- "App Store Connect API" --> Server Sys -- "paired device" --> Hub["Device Hub
Reports tab"] Org --> Dev(["Developer"]) Server --> Dev Hub --> Dev

The channels overlap on purpose. The Organizer shows aggregated data with no setup but with a delay and only from people who opted in; MetricKit arrives in the app within a day with the app’s own context attached; logs explain the sequence of events behind one report.

Logging with Logger

Logger writes to the unified logging system: a single, efficient log store shared by the system and every app. Messages are stored in a compressed binary form and formatted only when someone reads them, so logging is cheap enough to leave in release builds. Each logger has a subsystem (FieldNotes uses its bundle identifier prefix, com.example.fieldnotes) and a category that names a functional area. NoteStore's persistence logger from Persisting Models with SwiftData already follows this pattern; the other areas get shared loggers in the services package:

// Packages/FieldNotesKit/Sources/FieldNotesServices/Logging.swift
import OSLog

public extension Logger {
    private static let subsystem = "com.example.fieldnotes"

    /// Reads and writes of the SwiftData store (NoteStore keeps its own logger with the same category).
    static let persistence = Logger(subsystem: subsystem, category: "persistence")
    /// CloudKit and REST synchronization.
    static let sync = Logger(subsystem: subsystem, category: "sync")
    /// App Intents, Shortcuts and Siri.
    static let intents = Logger(subsystem: subsystem, category: "intents")
    /// Navigation and user interface events.
    static let ui = Logger(subsystem: subsystem, category: "ui")
}

The extension lives in a package target, which doesn’t get the app target’s main-actor default isolation, and Logger is Sendable, so these static properties can be used from any actor, a nonisolated delegate or a deinit. Keep the set of categories small and stable: they are what you filter by later.

Levels

The level says how important a message is, and decides whether the system keeps it:

Level Use it for Kept

debug

Detailed tracing while developing: every step of a sync.

Only while someone is streaming the log; not stored by default.

info

Helpful context that isn’t essential for diagnosis.

In memory; written to disk only when a fault or error follows.

notice (the default of log(_:))

Events that matter for understanding the app’s behavior: sync finished, store opened.

On disk, up to a storage limit.

error

Errors the app handled but that indicate a problem: a failed save.

On disk.

fault

Bugs: states that should be impossible.

On disk, with extra process information.

// FieldNotes/Sync/ManualSync.swift (the Sync Now button)
import OSLog
import FieldNotesServices

func syncNow(_ store: NoteStore) async {
    Logger.sync.debug("Sync requested with \(store.notes.count) local notes")
    do {
        try await store.refresh()
        Logger.sync.notice("Sync finished")
    } catch is CancellationError {
        Logger.sync.info("Sync cancelled")
    } catch {
        Logger.sync.error("Sync failed: \(error.localizedDescription, privacy: .public)")
    }
}

store.refresh() is NoteStore's call into its NoteSyncing syncer from App Architecture the Apple Way. Arguments to a Logger call are evaluated lazily, only when the level is enabled, so a debug message with a costly interpolation costs almost nothing in a release build.

Privacy in interpolation

Logs can be collected from a person’s device and attached to a bug report, so the logging system redacts dynamic values by default: interpolated strings and objects appear as <private> unless you mark them otherwise. Numbers, Booleans and dates are public by default. Say explicitly what may be shown:

// FieldNotes/Views/NoteEditor+Logging.swift
import OSLog
import FieldNotesModel
import FieldNotesServices

func logOpened(_ note: Note, account email: String) {
    Logger.ui.info("""
        Opened note \(note.id.uuidString, privacy: .public) \
        titled \(note.title, privacy: .private) \
        with \(note.tags.count) tags \
        for account \(email, privacy: .private(mask: .hash))
        """)
}

The note’s identifier is public, so two messages about the same note can be matched; the title is private, so it’s redacted outside a debugging session; the account e-mail is replaced by a stable hash, which still lets you tell whether two messages concern the same account. Error descriptions from system frameworks are usually safe to make public; error messages that echo user input are not. Never log passwords, tokens or health data, even as private values.

Reading logs: Xcode, Console and log

While FieldNotes runs from Xcode, its messages appear in the debug console with their level, subsystem and category. The console’s filter bar narrows the stream by level, subsystem, category or text, and Control-clicking a message offers to show or hide similar ones; each message also links back to the line that logged it. On the Mac, Console.app shows the log of the Mac and of connected devices, with the same filters and saved searches. The log command-line tool does the same in a terminal and in scripts:

# Live messages of the Mac build, including debug level
log stream --predicate 'subsystem == "com.example.fieldnotes"' --level debug

# Live messages of the booted Simulator, sync category only
xcrun simctl spawn booted log stream --style compact \
  --predicate 'subsystem == "com.example.fieldnotes" AND category == "sync"'

# The last hour on the Mac, after the fact
log show --last 1h --info --predicate 'subsystem == "com.example.fieldnotes"'

# Collect the log of a connected iPhone into an archive, then read it
sudo log collect --device --last 30m --output FieldNotes.logarchive
log show FieldNotes.logarchive --predicate 'subsystem == "com.example.fieldnotes" AND messageType == error'

The WWDC23 session Debug with structured logging tours the console’s filters and shows how to replace print with Logger step by step.

Reading the app’s own log with OSLogStore

When a person reports a problem from inside the app, a Send Diagnostics button can attach FieldNotes' recent log messages. OSLogStore reads the log programmatically. On iOS, iPadOS, watchOS and visionOS an app can only read entries of its own current process (.currentProcessIdentifier), which is enough for this purpose:

// FieldNotes/Diagnostics/RecentLogs.swift
import Foundation
import OSLog

nonisolated enum RecentLogs {
    /// FieldNotes' own messages since `interval` seconds ago, one per line, for a support e-mail.
    @concurrent
    static func export(last interval: TimeInterval = 3600) async throws -> String {
        let store = try OSLogStore(scope: .currentProcessIdentifier)
        let start = store.position(date: .now.addingTimeInterval(-interval))
        let predicate = NSPredicate(format: "subsystem == %@", "com.example.fieldnotes")
        return try store.getEntries(at: start, matching: predicate)
            .compactMap { $0 as? OSLogEntryLog }
            .map { "\($0.date.formatted(.iso8601)) [\($0.category)] \($0.composedMessage)" }
            .joined(separator: "\n")
    }
}

Reading the store is slow and blocking, so the function is @concurrent and runs off the main actor. Private values stay redacted in the result, and entries from before the current launch are not available: this is a support aid, not a crash log. On the Mac, OSLogStore.local() can read the whole system log, but only for processes with administrator rights.

Signposts with OSSignposter

A log message marks a moment; a signpost marks an interval, with a begin and an end, that Instruments draws on a timeline. FieldNotes defines one signposter in the Points of Interest category, which Instruments shows in its own track in every template:

// Packages/FieldNotesKit/Sources/FieldNotesServices/Signposts.swift
import OSLog

public enum FieldNotesSignposts {
    /// Intervals that matter to the person: loading, exporting, syncing.
    public static let loading = OSSignposter(subsystem: "com.example.fieldnotes", category: .pointsOfInterest)
}

// FieldNotes/Diagnostics/LaunchLoading.swift
import FieldNotesServices

/// Loads the store at launch, measured by a signpost interval and by MetricKit's extended launch.
func loadNotesAtLaunch(_ store: NoteStore) async {
    let signposter = FieldNotesSignposts.loading
    let state = signposter.beginInterval("Load notes", id: signposter.makeSignpostID())
    await MetricsReporter.shared.trackNotesLoad { await store.load() }
    signposter.endInterval("Load notes", state, "\(store.notes.count) notes")
}

beginInterval(_:id:) returns a state value that endInterval(_:_:_:) consumes, which works across await because the state is Sendable. For synchronous work, withIntervalSignpost(_:id:around:) wraps a closure in one call. Signposts are nearly free when no tool is recording. The same interval lets you filter an Instruments trace to exactly the load, as shown on Performance and Instruments, and the "Load notes" name is a StaticString, so it can’t leak user data. MetricsReporter is defined below.

MetricKit

MetricKit collects performance data on real devices while people use the app, and delivers it to the app itself. It provides two kinds of data. Metrics are daily aggregates, such as launch time histograms, hang time, animation hitches, CPU, memory, disk writes and network use, that show whether an area is getting better or worse. Diagnostics describe single events, such as a crash, a hang, a CPU or disk-write exception, a slow launch or (new in 27) a memory-limit termination, with a call stack tree that points at the code.

In the 27 releases the framework has a new Swift-first API around MetricManager, with async sequences and Codable reports; Apple describes it as the future of the framework, and features such as state reporting exist only there. MXMetricManager and its payload classes remain available and are not marked deprecated, which is what FieldNotes uses on 26.

MetricsReporter: receiving reports with MetricManager (27)

A MetricManager should be created at launch, so no report is lost, and kept alive, so its sequences keep delivering. FieldNotes wraps both paths in one object. It saves every report as JSON in the caches directory, from where the app’s regular sync can upload it:

// FieldNotes/Diagnostics/MetricsReporter.swift
#if canImport(MetricKit)
import Foundation
import MetricKit
import OSLog

@MainActor
final class MetricsReporter {
    static let shared = MetricsReporter()

    private let directory = URL.cachesDirectory.appending(path: "MetricKit", directoryHint: .isDirectory)
    private let log = Logger(subsystem: "com.example.fieldnotes", category: "metrics")
    private var listeners: [Task<Void, Never>] = []
    private var manager: AnyObject?              // a MetricManager on 27
    private var legacySubscriber: LegacySubscriber?

    /// Call once, as early as possible in the app's launch.
    func start() {
        guard listeners.isEmpty, legacySubscriber == nil else { return }
        if #available(iOS 27, macOS 27, visionOS 27, *) {
            startMetricManager()
        } else {
            let subscriber = LegacySubscriber(directory: directory)
            MXMetricManager.shared.add(subscriber)
            legacySubscriber = subscriber
        }
    }

    @available(iOS 27, macOS 27, visionOS 27, *)
    private func startMetricManager() {
        #if os(visionOS)
        let manager = MetricManager()           // visionOS 27: diagnostics only
        #else
        let manager = MetricManager(enabledStateReportingDomains: [Self.activityDomain])
        listeners.append(Task(name: "MetricKit metrics") {
            for await report in manager.metricReports { await self.handle(report) }
        })
        #endif
        listeners.append(Task(name: "MetricKit diagnostics") {
            for await report in manager.diagnosticReports { await self.handle(report) }
        })
        self.manager = manager
    }
}
#endif

The two tasks capture self strongly on purpose: MetricsReporter.shared lives as long as the process, and the sequences never finish. This is the one case where the unbounded loop and retain cycle of Debugging with Xcode and LLDB are harmless. Start the reporter from the app’s initializer, before the first scene appears:

// FieldNotes/FieldNotesApp.swift (excerpt, updated: diagnostics start at launch)
init() {
    // ... existing IntentRouter setup ...
    #if canImport(MetricKit)
    MetricsReporter.shared.start()
    #endif
}

Metric reports

A MetricReport covers one day. Its intervalEntries hold a full-day entry plus shorter breakdowns when data exists; each entry’s values are MetricResult cases grouped by MetricGroup (.cpu, .memory, .animation, .appLaunch and so on). Reports are Codable, so the whole report can be encoded and uploaded, while the app inspects only the few values it cares about:

// FieldNotes/Diagnostics/MetricsReporter.swift (continued)
#if canImport(MetricKit) && !os(visionOS)
@available(iOS 27, macOS 27, *)
extension MetricsReporter {
    func handle(_ report: MetricReport) async {
        for entry in report.intervalEntries {
            for case .peakMemory(let peak) in entry.values where peak.value.converted(to: .megabytes).value > 400 {
                log.notice("Peak memory \(peak.value.converted(to: .megabytes).value) MB in one interval")
            }
        }
        let encoder = JSONEncoder()
        encoder.userInfo[MetricReport.encodingFormatKey] = MetricReport.EncodingFormat.byStateReportingDomain
        do {
            let data = try encoder.encode(report)
            try await Self.save(data, as: "metrics-\(Date.now.timeIntervalSince1970).json", in: directory)
        } catch {
            log.error("Saving a metric report failed: \(error.localizedDescription, privacy: .public)")
        }
    }
}
#endif

#if canImport(MetricKit)
extension MetricsReporter {
    @concurrent
    static func save(_ data: Data, as name: String, in directory: URL) async throws {
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        try data.write(to: directory.appending(path: name), options: .atomic)
    }
}
#endif

A single device’s report says little; the value is in aggregating reports from many devices on a server, by app version, device and state, and watching the trend. That server side (ingestion, storage, dashboards and alerts) is what the Prometheus & Monitoring section covers.

Diagnostic reports

Diagnostics arrive one event at a time, soon after the event (for a crash, typically at the next launch). Each DiagnosticReport has an environment (app version, OS, device, whether it’s a TestFlight build) and a result, a DiagnosticResult enumeration with one case per diagnostic kind:

// FieldNotes/Diagnostics/MetricsReporter.swift (continued)
#if canImport(MetricKit)
@available(iOS 27, macOS 27, visionOS 27, *)
extension MetricsReporter {
    func handle(_ report: DiagnosticReport) async {
        switch report.result {
        case .crash(let crash):
            log.error("Crash diagnostic: exception \(crash.exceptionType ?? -1), signal \(crash.signal ?? -1)")
        case .hang(let hang):
            log.error("Hang of \(hang.hangDuration.converted(to: .milliseconds).value) ms")
        case .memoryException:
            log.error("Terminated for exceeding the memory limit")
        default:
            break
        }
        do {
            let data = try JSONEncoder().encode(report)
            try await Self.save(data, as: "diagnostic-\(Date.now.timeIntervalSince1970).json", in: directory)
        } catch {
            log.error("Saving a diagnostic report failed: \(error.localizedDescription, privacy: .public)")
        }
    }
}
#endif

A CrashDiagnostic carries the exception type, code and signal, the termination reason and, new in 27, a termination category that says how the crash was counted in the metrics. Its callStackTree contains unsymbolicated addresses with binary UUIDs, so symbolicate them on the server with the build’s dSYMs, as described below.

State reporting (27)

A metric such as "4.5 seconds of hitches per day" blends every screen of the app. With the StateReporting framework, the app reports which state it is in, and MetricKit reports metrics per state: hitches while reading a note versus while browsing the list. A domain is one functional area with at most one active state at a time, and optional stable metadata describes the state further. Keep the labels few and fixed, and put changing values in buckets:

// FieldNotes/Diagnostics/MetricsReporter+State.swift
#if canImport(MetricKit)
import MetricKit
import StateReporting

@available(iOS 27, macOS 27, visionOS 27, *)
@ReportableMetadata
struct NoteListMetadata {
    let size: String      // "small", "medium" or "large": a bucket, never the raw count
}

extension MetricsReporter {
    @available(iOS 27, macOS 27, *)
    static var activityDomain: StateReportingDomain { "com.example.fieldnotes.activity" }

    /// Reports what the person is doing; nil clears the state.
    func reportActivity(_ label: String?, noteCount: Int) {
        guard #available(iOS 27, macOS 27, visionOS 27, *) else { return }
        let reporter = StateReporter.reporter(for: "com.example.fieldnotes.activity",
                                              stableMetadata: NoteListMetadata.self)
        let size = noteCount < 50 ? "small" : noteCount < 500 ? "medium" : "large"
        reporter.reportTransition(to: label, stableMetadata: NoteListMetadata(size: size))
    }
}
#endif

// FieldNotes/Views/ContentView.swift (excerpt, updated)
.onChange(of: selectedNote, initial: true) {
    MetricsReporter.shared.reportActivity(selectedNote == nil ? "browsing" : "reading",
                                          noteCount: store.notes.count)
}

The domain must also be passed to MetricManager(enabledStateReportingDomains:), as startMetricManager() does, or no per-state metrics are produced. The reporter for a domain is created once per process with fixed metadata types; asking for the same domain with different types crashes, so all FieldNotes code goes through reportActivity(_:noteCount:). Transitions are rate-limited: report screen changes, not every scroll offset. Reported states also appear in Instruments in the Points of Interest track, which is the quickest way to check them before shipping.

Extended launch and custom signpost metrics

Launch time as MetricKit measures it ends at the first frame. If the app then shows a spinner while loading, that time is invisible unless the app reports it. trackLaunchTask(id:onTrackingError:_:) wraps such work and adds it to the report as an extended launch measurement. Custom intervals, such as a notebook export, can be measured with mxSignpost on a log handle from MetricManager, which MetricKit aggregates with CPU time, memory and disk writes:

// FieldNotes/Diagnostics/MetricsReporter.swift (continued)
#if canImport(MetricKit)
extension MetricsReporter {
    /// Used by loadNotesAtLaunch(_:) above.
    func trackNotesLoad(_ load: () async -> Void) async {
        #if !os(visionOS)
        if #available(iOS 27, macOS 27, *), let manager = manager as? MetricManager {
            await manager.trackLaunchTask(id: "load-notes", onTrackingError: { error in
                self.log.notice("Launch task not tracked: \(String(describing: error), privacy: .public)")
            }, load)
            return
        }
        #endif
        await load()
    }

    func exportIntervalDidChange(started: Bool) {
        #if !os(visionOS)
        guard #available(iOS 27, macOS 27, *) else { return }
        mxSignpost(started ? .begin : .end, log: MetricManager.logHandle(category: "Export"), name: "NotebookExport")
        #endif
    }
}
#endif

Only mxSignpost fills in the resource figures of a SignpostIntervalMetric; an OSSignposter interval on the same handle shows up in Instruments but not with those measurements. NotebookExportController from Background Execution calls exportIntervalDidChange(started:) when an export starts and ends.

The MXMetricManager path for 26

On 26, the same reports arrive through a subscriber object with two callbacks, as MXMetricPayload and MXDiagnosticPayload classes that can produce JSON themselves. The system calls the subscriber on a background queue, so it is a nonisolated class with only immutable state:

// FieldNotes/Diagnostics/MetricsReporter.swift (continued)
#if canImport(MetricKit)
extension MetricsReporter {
    nonisolated final class LegacySubscriber: NSObject, MXMetricManagerSubscriber {
        private let directory: URL
        private let log = Logger(subsystem: "com.example.fieldnotes", category: "metrics")

        init(directory: URL) { self.directory = directory }

        func didReceive(_ payloads: [MXMetricPayload]) {
            for payload in payloads { save(payload.jsonRepresentation(), prefix: "metrics") }
        }

        func didReceive(_ payloads: [MXDiagnosticPayload]) {
            for payload in payloads { save(payload.jsonRepresentation(), prefix: "diagnostic") }
        }

        private func save(_ data: Data, prefix: String) {
            do {
                try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
                let name = "\(prefix)-\(Date.now.timeIntervalSince1970)-mx.json"
                try data.write(to: directory.appending(path: name), options: .atomic)
            } catch {
                log.error("Saving a MetricKit payload failed: \(error.localizedDescription, privacy: .public)")
            }
        }
    }
}
#endif

The two JSON formats differ, so the server tags each file by its -mx suffix. When FieldNotes raises its deployment minimum to 27, the subscriber and the #available branch are deleted and nothing else changes.

Testing MetricKit

MetricKit delivers reports on a system-chosen schedule and not in the Simulator. To exercise the code, run FieldNotes from Xcode on a device and choose Debug > Simulate MetricKit Payloads; the app receives sample metric and diagnostic reports immediately. To produce a real crash diagnostic, crash the app on purpose while it is not attached to the debugger, then launch it again.

This needs a physical device running 26 or 27 and a signing team; MetricKit doesn’t deliver reports in the Simulator.

Crash Reports

When the system terminates FieldNotes abnormally, it writes a crash report (.ips, a JSON format that Xcode and Console.app display as text). It is the most important single artifact for a crash, and it has a fixed anatomy.

Anatomy of a crash report

Incident Identifier: 6D1E1A5C-4F7B-4B7A-9A3E-1C2D3E4F5A6B
Hardware Model:      iPhone17,1
Process:             FieldNotes [4312]
Identifier:          com.example.fieldnotes
Version:             2.3 (231)
Code Type:           ARM-64 (Native)
OS Version:          iPhone OS 27.0 (24A330)
Date/Time:           2026-09-21 14:03:11.482 +0200
Launch Time:         2026-09-21 14:01:57.019 +0200

Exception Type:  EXC_BREAKPOINT (SIGTRAP)
Termination Reason: SIGNAL 5 Trace/BPT trap: 5

Triggered by Thread:  0

Thread 0 name:   Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0   FieldNotes           0x00000001029c19b4 NoteSiteBadge.body.getter + 212 (NoteSiteBadge.swift:18)
1   FieldNotes           0x00000001029c16d0 closure #1 in NoteList.body.getter + 248 (NoteList.swift:31)
2   SwiftUI              0x00000001a6cff6ec ...

Binary Images:
0x1029a0000 - 0x102a3ffff FieldNotes arm64 <3f2a9c1e5b6d4e7f8a9b0c1d2e3f4a5b> /private/var/containers/Bundle/.../FieldNotes

Read it top to bottom:

  • Header: app version and build, OS version, device model, launch and crash time. Group reports by these to see whether a crash is new in one build, one OS or one device.

  • Exception information: how the process died. EXC_BREAKPOINT (SIGTRAP) on ARM is a Swift runtime trap (force-unwrapping nil, an out-of-range index, a failed precondition, a fatalError); in the example, a hypothetical NoteSiteBadge view force-unwraps note.tags.first for a note without tags; EXC_BAD_ACCESS is an invalid memory access; EXC_CRASH (SIGABRT) is an abort, often an uncaught Objective-C exception; EXC_CRASH (SIGKILL) with a watchdog termination reason means the main thread was blocked too long.

  • Termination reason and diagnostic messages: the component that ended the process and why, such as a code signing failure or a missing privacy purpose string (see Debugging with Xcode and LLDB).

  • Backtraces: the crashed thread first, and the Last Exception Backtrace for Objective-C exceptions. Read from frame 0 downward until you reach your own code.

  • Binary images: every loaded binary with its build UUID, which is what symbolication matches against.

Symbolication and dSYMs

A release build doesn’t contain its debug symbols. They go into a separate dSYM bundle per binary (app, extensions, frameworks), tied to the binary by the same build UUID that appears in the crash report. Without the matching dSYM, frames show only addresses. Keep Debug Information Format at DWARF with dSYM File for Release, keep every archive you distribute, and include symbols when uploading to App Store Connect so the Organizer symbolicates for you. For a report that arrives by e-mail:

# Build setting (Release): produce dSYMs
#   DEBUG_INFORMATION_FORMAT = dwarf-with-dsym

# Check that a dSYM matches the crash report's UUID
dwarfdump --uuid FieldNotes.xcarchive/dSYMs/FieldNotes.app.dSYM

# Find a dSYM by UUID anywhere on the Mac (Spotlight indexes archives)
mdfind "com_apple_xcode_dsym_uuids == 3F2A9C1E-5B6D-4E7F-8A9B-0C1D2E3F4A5B"

# Symbolicate a whole report with LLDB's crashlog command
xcrun crashlog ~/Downloads/FieldNotes-2026-09-21-140311.ips

# Symbolicate one address: load address of the binary image, then the frame address
atos -arch arm64 -o FieldNotes.app.dSYM/Contents/Resources/DWARF/FieldNotes \
     -l 0x1029a0000 0x00000001029c16d0

Opening an .ips file in Xcode also symbolicates it against the projects and archives it knows. If system frames stay unsymbolicated, Xcode needs the device support files for that OS version; if frames of a third-party binary framework stay unsymbolicated, ask its vendor for the dSYM.

Where crash reports come from

  • Xcode Organizer: reports from TestFlight testers (always) and App Store users who share analytics, grouped by signature, symbolicated when you uploaded symbols.

  • Device Hub: the Reports tab of a paired device lists its crash reports.

  • The device: Settings > Privacy & Security > Analytics & Improvements > Analytics Data lists reports named after the app, and JetsamEvent reports for memory terminations, which the person can share. Watch reports are on the paired iPhone; Mac reports are in Console.app.

  • MetricKit: CrashDiagnostic reports, delivered to the app itself.

  • While debugging: the debugger intercepts the crash; detach (Debug > Detach) to let the system write the report.

Watchdog terminations, invalid code signatures, thermal events and jetsam events don’t appear in the Crashes organizer; collect them from the device.

The Xcode Organizer (27)

The Organizer (Window > Organizer) shows aggregated, anonymized data from TestFlight and the App Store for each app version, with no code in the app. Xcode 27 reorganizes it around what needs attention:

  • Insights overview: the first page. It summarizes high-impact regressions across metrics and diagnostics, top and trending signatures, and the metrics most likely to need work. Opt in to regression notifications to be told when a new version regresses a metric sharply.

  • Metrics: launch time, hang rate, memory, battery, disk writes and more, per version and device, with typical and high percentiles. The new Hitches metric replaces the old Scrolling metric and covers every animation, and new Storage metrics track Documents & Data and app size across releases.

  • Metric goals: goals now exist for battery usage, disk writes, hang rate, hitches, memory and storage, based on similar apps or on the app’s own history; the chart shows where the current version stands.

  • Reports: Crashes, Energy, Hangs, Disk Writes and Launches, each grouped by signature with a backtrace and its trend. Generate Recommendations in the inspector hands a report’s stack to Xcode’s coding assistant.

  • Feedback: screenshots with comments, and crash feedback, sent by TestFlight testers.

The same data is available to scripts and dashboards through the App Store Connect API, which is how FieldNotes' metrics server pulls the Organizer’s hang signatures next to its own MetricKit data:

# $ASC_TOKEN is a signed App Store Connect API JWT; $BUILD_ID identifies one uploaded build.
# -g (--globoff) keeps curl from reading the brackets in filter[...] as a URL glob; -sS --fail-with-body shows errors.
curl -sS -g --fail-with-body -H "Authorization: Bearer $ASC_TOKEN" \
  "https://api.appstoreconnect.apple.com/v1/builds/$BUILD_ID/diagnosticSignatures?filter[diagnosticType]=HANGS&limit=20"

curl -sS -g --fail-with-body -H "Authorization: Bearer $ASC_TOKEN" -H "Accept: application/vnd.apple.xcode-metrics+json" \
  "https://api.appstoreconnect.apple.com/v1/builds/$BUILD_ID/perfPowerMetrics?filter[metricType]=HANG,LAUNCH"

API keys and tokens are created in App Store Connect under Users and Access, which needs a paid developer account; the API itself is covered on CI/CD with Xcode Cloud and the Command Line.

TestFlight feedback is also listed in App Store Connect under the app’s TestFlight tab. Testers send it by taking a screenshot while using a TestFlight build, or from the prompt that appears after a crash; App Store Connect, TestFlight and App Review covers setting up the testers.

Availability and Requirements

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

Logger, OSSignposter, OSLogStore

Yes

Yes

Yes

Yes

Yes

OSLogStore.local() (whole system log)

Not available

Not available

Yes

Not available

Not available

MXMetricManager and payloads

Yes

Yes

Yes

Not available

Yes

MetricManager, diagnosticReports

New in 27

New in 27

New in 27

Not available

New in 27

MetricManager.metricReports, trackLaunchTask, logHandle(category:)

New in 27

New in 27

New in 27

Not available

Not available

StateReporting (StateReporter, @ReportableMetadata)

New in 27

New in 27

New in 27

New in 27

New in 27

Per-state metrics (enabledStateReportingDomains)

New in 27

New in 27

New in 27

Not available

Not available

Crash reports and symbolication

Yes

Yes

Yes

Yes (on the paired iPhone)

Yes

Organizer Insights, Hitches, Storage, new metric goals

New in Xcode 27

New in Xcode 27

New in Xcode 27

New in Xcode 27

New in Xcode 27

Requirements

  • MetricKit reports need a physical device; they are not delivered in the Simulator. Simulate MetricKit Payloads needs the app running from Xcode on a device.

  • Organizer metrics and crash reports need builds distributed through TestFlight or the App Store, which need a paid developer account; App Store users contribute only if they share analytics with developers.

  • Symbolication needs the dSYMs of the exact build; keep every distributed archive.

  • The App Store Connect API needs an API key created by an Account Holder or Admin.

Where to Go Next

Logs, metrics and crash reports tell you that something is slow or broken in the field. Reproducing it and finding the cause happens with the tools on Debugging with Xcode and LLDB and Performance and Instruments; the FieldNotesSignposts.loading intervals defined here reappear in Instruments there. For the server that aggregates MetricKit JSON from many devices, start with Prometheus & Monitoring.

References