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:
- Launch app, tap a button that triggers a native crash
(fatalError("...") or force-unwrap of a nil String?).
- Process dies as expected.
- Relaunch the app.
- 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:
- 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.
- Remove
sanitized(stackFrames:) entirely — or make it a no-op — so exported
stack frames are preserved.
- Don't unconditionally purge on parse failure. Keep the data around for one
retry, or at minimum emit a distinct telemetry event when dropping.
- 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
Summary
Native iOS crashes (Swift
fatalError, force-unwrap of nil, etc.) are neverdelivered as
type=crashevents when the app is relaunched. Observed in aFlutter app using
faro: ^0.14.0withenableCrashReporting: true. Theequivalent Android path (
ApplicationExitInfoviaFaroPlugin.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.swiftsurfaces several bugsthat 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:
Repro:
(
fatalError("...")or force-unwrap of a nilString?).{app_id=...} | logfmt | type="crash"in Loki.Result: no
type=crashevent ever appears. The post-crash session startsnormally and emits
session_start,view_changed, etc., but nothing carryingthe crash data.
In the same sessions,
pushErrorfrom Dart does work, so the transport itselfis 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 arrayIf the input is non-empty, this unconditionally returns
[]. Called fromCrashReportExporter.formattedStack→ every exported crash loses all stackframes. Looks like an abandoned implementation: the inner
asDictionary()isunreachable and empty. This is very likely a committed WIP.
2. Crash payload bypasses the normal Faro transport
A raw POST with an ad-hoc envelope (
{exceptions, meta}) is sent directly tothe collector URL, bypassing
FaroTransport/ the Dart-side batching. Thismeans:
OfflineTransportwon't see it).confirmed whether the collector accepts both, but it's at least a candidate
reason for silent drops.
x-api-token;FaroTransportusesx-api-key. Likelyharmless for collectors that authenticate via the app key in the URL path,
but inconsistent.
URLSession.dataTaskis fired, then immediately afterwards thecaller runs
crashReporter.purgePendingCrashReport(). If the POST fails orthe app is killed again before it completes, the report is lost with no
retry path.
3. Swallowing of load/parse errors
Any thrown error is logged to stdout and then
purgePendingCrashReport()iscalled 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)
PLCrashReporteris capturing the crash at all on iOS 26.4Simulator. The SDK uses
.BSDsignal handler type; SwiftfatalErrorandforce-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.
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:
sendPostRequestpath. Route captured crashes throughthe same
FaroTransportthe rest of the SDK uses, so they benefit frombatching, retries, and
OfflineTransport.sanitized(stackFrames:)entirely — or make it a no-op — so exportedstack frames are preserved.
retry, or at minimum emit a distinct telemetry event when dropping.
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.0https://faro-collector-dev-us-east-0.grafana-dev.net/collect/<appKey>Related
same overall "native crash pipelines are fragile" theme).