Skip to content

[BUG] iOS native crashes are never delivered — broken CrashReportingIntegration pipeline #220

@robert-northmind

Description

@robert-northmind

Summary

Native iOS crashes (Swift fatalError, force-unwrap of nil, etc.) are never
delivered as type=crash events when the app is relaunched. Observed in a
Flutter app using faro: ^0.14.0 with enableCrashReporting: true. The
equivalent Android path (ApplicationExitInfo via FaroPlugin.getCrashReport)
does deliver crash events, so the problem is iOS-specific.

Independent of whether a crash is captured at all by PLCrashReporter, reading
ios/faro/Sources/faro/CrashReportingIntegration.swift surfaces several bugs
that would prevent a captured crash from being usable, and at least one that
would strip it of any value if it did arrive.

Observed behavior

Tested on iOS Simulator (iPhone 17, iOS 26.4) with a Flutter app configured as:

faro.runApp(
  optionsConfiguration: FaroConfig(
    appName: 'quickpizza-flutter',
    enableCrashReporting: true,
    // ...
  ),
  appRunner: () { runApp(...); },
);

Repro:

  1. Launch app, tap a button that triggers a native crash
    (fatalError("...") or force-unwrap of a nil String?).
  2. Process dies as expected.
  3. Relaunch the app.
  4. Look for {app_id=...} | logfmt | type="crash" in Loki.

Result: no type=crash event ever appears. The post-crash session starts
normally and emits session_start, view_changed, etc., but nothing carrying
the crash data.

In the same sessions, pushError from Dart does work, so the transport itself
is healthy and the app key / collector URL are valid.

Code-level findings (v0.14.0)

Reading ios/faro/Sources/faro/CrashReportingIntegration.swift:

1. sanitized(stackFrames:) always returns an empty array

private func sanitized(stackFrames: [StackFrame]) -> [StackFrame] {
    guard let _ = stackFrames.last else {
        return stackFrames
    }
    return []
    func asDictionary(){

    }
}

If the input is non-empty, this unconditionally returns []. Called from
CrashReportExporter.formattedStack → every exported crash loses all stack
frames. Looks like an abandoned implementation: the inner asDictionary() is
unreachable and empty. This is very likely a committed WIP.

2. Crash payload bypasses the normal Faro transport

func sendCrashReport(crash: Dictionary<String,Any>, config: Dictionary<String, Any>) {
    let meta = [
        "app":     config["app"],
        "session": config["session"],
    ]
    var crashPayload: Dictionary<String,Any> = [:]
    crashPayload["exceptions"] = [crash]
    crashPayload["meta"] = meta
    sendPostRequest(
        collector: config["collectorUrl"] as! String,
        apiToken:  config["apiKey"] as! String,
        payload:   crashPayload
    ) { ... }
}

A raw POST with an ad-hoc envelope ({exceptions, meta}) is sent directly to
the collector URL, bypassing FaroTransport / the Dart-side batching. This
means:

  • No buffering, no retry, no offline queuing (OfflineTransport won't see it).
  • The envelope shape differs from what the Dart transport sends — I haven't
    confirmed whether the collector accepts both, but it's at least a candidate
    reason for silent drops.
  • The HTTP header is x-api-token; FaroTransport uses x-api-key. Likely
    harmless for collectors that authenticate via the app key in the URL path,
    but inconsistent.
  • The async URLSession.dataTask is fired, then immediately afterwards the
    caller runs crashReporter.purgePendingCrashReport(). If the POST fails or
    the app is killed again before it completes, the report is lost with no
    retry path.

3. Swallowing of load/parse errors

if crashReporter.hasPendingCrashReport() {
    do {
        let data = try crashReporter.loadPendingCrashReportDataAndReturnError()
        let report = try PLCrashReport(data: data)
        var crashReport = try CrashReport(from: report)
        // ... minify / export / send ...
    } catch let error {
        print("CrashReporter failed to load and parse with error: \(error)")
    }
}
crashReporter.purgePendingCrashReport()

Any thrown error is logged to stdout and then purgePendingCrashReport() is
called unconditionally. A broken payload or a parsing failure deletes the
evidence from disk without any telemetry signalling that it happened.

What I did NOT verify (calling out uncertainty)

  • Whether PLCrashReporter is capturing the crash at all on iOS 26.4
    Simulator. The SDK uses .BSD signal handler type; Swift fatalError and
    force-unwrap traps can bypass BSD handlers on simulator in some
    configurations. It's plausible (not proven) that on simulator the crash
    never reaches disk in the first place, which would make the bugs above
    irrelevant for this specific repro but still real bugs for on-device use.
  • Whether the collector rejects/drops the ad-hoc envelope. I haven't tested
    ingestion of {exceptions:[...], meta:{...}} POSTs independently.

The most useful next verification would be: run on a real iOS device, trigger
the same crashes, and see if anything arrives. If it does, the bugs above
still apply (empty frames, no retry). If it doesn't, we also have a
capture-side simulator problem to split into its own issue.

Suggested fix direction

Rather than patching the existing file piecemeal, I'd argue for a larger
rewrite along these lines:

  1. Delete the ad-hoc sendPostRequest path. Route captured crashes through
    the same FaroTransport the rest of the SDK uses, so they benefit from
    batching, retries, and OfflineTransport.
  2. Remove sanitized(stackFrames:) entirely — or make it a no-op — so exported
    stack frames are preserved.
  3. Don't unconditionally purge on parse failure. Keep the data around for one
    retry, or at minimum emit a distinct telemetry event when dropping.
  4. Add a small integration test that simulates a PLCrashReport-shaped dump on
    disk and asserts the SDK produces a payload with non-empty frames, correct
    envelope.

Note: session attribution (crash reported against the post-crash session
instead of the crashed one) is a known cross-platform issue tracked in #151
and intentionally left out of scope here.

Environment

  • faro: 0.14.0
  • Flutter 3.35.x (Dart 3.11.0 stable)
  • iOS Simulator: iPhone 17 / iOS 26.4 (arm64, Apple Silicon host)
  • Collector: https://faro-collector-dev-us-east-0.grafana-dev.net/collect/<appKey>

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions