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
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 |
|---|---|---|
|
Detailed tracing while developing: every step of a sync. |
Only while someone is streaming the log; not stored by default. |
|
Helpful context that isn’t essential for diagnosis. |
In memory; written to disk only when a fault or error follows. |
|
Events that matter for understanding the app’s behavior: sync finished, store opened. |
On disk, up to a storage limit. |
|
Errors the app handled but that indicate a problem: a failed save. |
On disk. |
|
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-unwrappingnil, an out-of-range index, a failedprecondition, afatalError); in the example, a hypotheticalNoteSiteBadgeview force-unwrapsnote.tags.firstfor a note without tags;EXC_BAD_ACCESSis 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 Backtracefor 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
JetsamEventreports for memory terminations, which the person can share. Watch reports are on the paired iPhone; Mac reports are in Console.app. -
MetricKit:
CrashDiagnosticreports, 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 |
|---|---|---|---|---|---|
|
Yes |
Yes |
Yes |
Yes |
Yes |
|
Not available |
Not available |
Yes |
Not available |
Not available |
|
Yes |
Yes |
Yes |
Not available |
Yes |
|
New in 27 |
New in 27 |
New in 27 |
Not available |
New in 27 |
|
New in 27 |
New in 27 |
New in 27 |
Not available |
Not available |
StateReporting ( |
New in 27 |
New in 27 |
New in 27 |
New in 27 |
New in 27 |
Per-state metrics ( |
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
-
Apple Developer Documentation — Generating Log Messages from Your Code
-
Apple Developer Documentation -- trackLaunchTask(id:onTrackingError:_:)
-
Apple Developer Documentation — Track performance by app state using MetricKit
-
Apple Developer Documentation — Getting started with StateReporting
-
Apple Developer Documentation — Diagnosing issues using crash reports and device logs
-
Apple Developer Documentation — Examining the fields in a crash report
-
Apple Developer Documentation — Adding identifiable symbol names to a crash report
-
Apple Developer Documentation — Building your app to include debugging information
-
Apple Developer Documentation — Acquiring crash reports and diagnostic logs
-
Apple Developer Documentation — Analyzing the performance of your shipping app
-
Apple Developer Documentation -- List all diagnostic signatures for a build