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.
  • SDKRetentionFlow.submitCrashReport(...) posts a report directly, and RetentionFlow.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 independently emailField.required if 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).

The hosted page never sees the device it's browsed from as a device it can diagnose — it collects a description (and optionally a pasted log). Structured diagnostics come from the SDK.

SDK: submit programmatically

Available on iOS 16+ and macOS 13+. Call RetentionFlow.configure(_:) first — see the iOS & macOS SDK guide.

swift
// 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

swift
// 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

swift
// 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(), the CrashReportView SwiftUI screen, and presentCrashReport(...) — the presenter is compiled per platform (UIKit sheet on iOS, an NSWindow on macOS).
  • iOS only: the from presenter: parameter on presentCrashReport (it takes a UIViewController, 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.

swift
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 most maxBytes (32 KB by default) and returns nil when the file is missing, unreadable or empty.
Nothing about attachments can fail a report. Every cap is enforced by silently clamping or dropping — an otherwise-good crash report is never rejected because a log was too big.

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.