Crash reporting
A crash flow is two things at once: a hosted page where a user describes what went wrong, and a programmatic submit path in the iOS & macOS SDK that attaches real diagnostics — exception, stack trace, device, OS, app version. Both land in the same triage inbox in your dashboard. This is user-reported crash intake, not silent telemetry: a human message is always required.
The two paths
- Hosted page —
/crash/{appSlug}. Configured, versioned and published like every other flow. Good for an App Store support link, a website footer, or a “something broke” link in an email. - SDK —
RetentionFlow.submitCrashReport(...)posts a report directly, andRetentionFlow.presentCrashReport()shows a native form built from the same published config. The SDK path is the one that can attach diagnostics and logs.
Both write to the same public endpoint, POST /api/public/crash, and the same CrashSubmission table. Reports are tagged with a source: web (hosted page), sdk (user-initiated in-app), or sdk_auto(a report carrying a crash captured by the SDK's monitor).
The hosted page
Create a crash flow in the dashboard, edit the draft, preview it, and publish — the same draft/publish/version model as cancel, feedback or report flows. Only a published flow accepts submissions; an unpublished one returns 404 to both the page and the SDK.
What the page collects:
- Message— required. What happened, in the user's words. Capped at 2000 characters.
- Crash log — optional textarea, off by default (
logField.enabled). Whatever the user pastes is stored as the report's stack trace (capped at 64 KB). - Email — optional field (
emailField.enabled), and independentlyemailField.requiredif you want a reply address. When the field is off, a web submission that carries an email is rejected.
The rest of the config is presentation: intro (title, subtitle, message placeholder, submit label, optional legal line), success (title, body, optional CTA label + URL), colorScheme, and hero (eyebrow, accent colour, theme, title font).
SDK: submit programmatically
Available on iOS 16+ and macOS 13+. Call RetentionFlow.configure(_:) first — see the iOS & macOS SDK guide.
// Fetch the published config + app brand (throws .notOpen if unpublished):
let form = try await RetentionFlow.crashReportForm()
// Submit. `diagnostics` defaults to a fresh device/OS/app snapshot.
try await RetentionFlow.submitCrashReport(
message: "Crashed while exporting",
email: "user@example.com", // optional
diagnostics: .current(), // or nil to send none
flowSlug: nil // target a non-primary crash flow
)CrashDiagnostics carries exceptionName, exceptionReason, stackTrace, platform, osVersion, deviceModel, appVersion, buildNumber and crashedAt. Every field is optional; CrashDiagnostics.current() fills in the device/OS/app snapshot with no exception info. Failures throw CrashReportError (.notConfigured, .notOpen, .http, .transport, .decode).
Present the native form
// One call — a sheet on iOS, a window on macOS:
RetentionFlow.presentCrashReport(userId: user.id) { /* submitted */ }
// Or embed the SwiftUI view in your own hierarchy:
CrashReportView(userId: user.id) { dismiss() }The native form renders the published config (message + optional email), shows a summary of the diagnostics it will attach with an opt-out toggle, and posts through the same endpoint.
Detect a crash and offer a pre-filled report
// At launch, right after configure(_:):
RetentionFlow.enableCrashDetection()
// Later — e.g. on your first screen:
if RetentionFlow.pendingCrash != nil {
RetentionFlow.presentCrashReport() // pre-fills the capture
// …or drop it silently:
// RetentionFlow.clearPendingCrash()
}enableCrashDetection() is a best-effort monitor: it captures uncaught NSExceptions (name, reason, call stack) and fatal signals (SIGSEGV and friends — signal name only), persists them locally, and exposes them on the next launch via RetentionFlow.pendingCrash. It never uploads anything by itself — the user submits. It is not a full crash reporter: no symbolication, no mach-exception handling. Keep your usual crash tooling if you need that.
What runs on macOS
- Both platforms:
crashReportForm(),submitCrashReport(...),CrashDiagnostics,CrashAttachment,enableCrashDetection(),pendingCrash,clearPendingCrash(), theCrashReportViewSwiftUI screen, andpresentCrashReport(...)— the presenter is compiled per platform (UIKit sheet on iOS, anNSWindowon macOS). - iOS only: the
from presenter:parameter onpresentCrashReport(it takes aUIViewController, which doesn't exist on macOS), and the shake menu's.reportCrash()item — shake detection is built on UIKit motion events.
Attachments: named text logs
CrashAttachment lets you send small named text logs alongside a report — console output, recent breadcrumbs, a device-state dump, a rolling log file. Text only. There is no storage backend behind this: attachments are stored inline with the row, so no screenshots, no crash dumps, no binaries.
try await RetentionFlow.submitCrashReport(
message: "Crashed on the export screen",
attachments: [
CrashAttachment(name: "breadcrumbs", text: recentEvents.joined(separator: "\n")),
CrashAttachment(name: "device-state", text: deviceStateDump),
// Read a small text file (truncated to maxBytes; nil if missing/empty):
CrashAttachment.file(named: "app.log", at: logFileURL),
].compactMap { $0 }
)
// The native form forwards host-provided logs with whatever the user submits:
RetentionFlow.presentCrashReport(attachments: [
CrashAttachment(name: "breadcrumbs", text: breadcrumbs),
])Limits
- 5 attachments per report. Extras are dropped, not rejected.
- 32 KB of content each — longer content is truncated.
- 128 KB total across all attachments in one report; once the budget is exhausted the remaining attachments are cut.
- Attachment names are trimmed to 120 characters, and an unnamed attachment gets an auto name (
log-1,log-2, …). - Entries with empty content are dropped. On the SDK side,
CrashAttachment.file(named:at:maxBytes:)reads at mostmaxBytes(32 KB by default) and returnsnilwhen the file is missing, unreadable or empty.
Reviewing crashes
The crash inbox lists reports newest-first with search (message, exception name, exception reason, email), a date range, and a status filter. Each report shows its diagnostics as chips (platform, device, OS, app version + build), the reporter's message, the source tag, location and user agent, and both the stack trace and each attachment as collapsible sections with a line count.
Triage is three statuses, set inline on each report:
new— untriaged. Every report starts here, and the header shows how many are still awaiting review.reviewed— you've read it.resolved— dealt with.
Moving a report off new stamps reviewedAt, shown as “triaged” on the row.
CSV export
Export CSV in the crash inbox streams every crash report for the app: created-at, status, crashed-at, exception name and reason, platform, OS version, device model, app version, build number, email, source, country, user agent, message, stack trace, and attachments as one JSON column.
Via MCP / AI agents
Agents can drive the whole loop: get_crash_flow, update_crash_draft and publish_crash_flow for configuration, then list_crash_submissions and set_crash_report_status for triage.
See the iOS & macOS SDK for install and configuration.