Bug & crash reporting
Nothing leaves the device without the user's say-so. Crash evidence is captured automatically, but uploading it always waits for explicit consent on the next launch.
/reports/*: automatic capture gated by next-launch consent, and the manual report dialog.The problem the session marker solves
Errors delivered to FlutterError.onError are, by definition, errors the framework caught and recovered from — their existence says nothing about whether the app actually died. Early versions of this flow prompted "Civiq closed unexpectedly" after any session that had logged a recoverable error, and couldn't tell an actual crash from the OS routinely reclaiming a backgrounded app, which happens constantly on both platforms.
The fix is a foreground-session marker: session_active.marker is written on every resume and deleted on every pause/detach. A crash report queued during a session is only believed on the next launch if the marker is still present — meaning the process died while on screen. A clean close or background reclaim deletes the marker (or never had one), and the queued report is silently discarded. Reading the marker fails closed: an unreadable marker must never invent a crash.
Capture
Both global hooks route into ReportingService: FlutterError.onError for framework errors and PlatformDispatcher.onError for everything else. Framework errors pass a non-fatal filter first — silent errors, image-loading failures (a rep photo 404 or an offline device raises one while the widget happily renders its fallback), and layout overflows are logged but never queued. The pending queue is exactly one deep: pending_crash_report.json, newest crash wins, written atomically, with a reentrancy guard so a crash during capture can't recurse.
Consent and upload
On the next launch, if the previous session ended unexpectedly, the home page shows a non-dismissable prompt after the first frame: the error summary (never the stack), an optional "what were you doing" note, and a plain-language disclosure of what gets sent. Three choices: Send uploads to POST /reports/crash with the stack trace, diagnostics, and the note (the payload hardcodes user_consented_to_send: true — consent is structural, not implied); Delete discards; Not now defers, leaving the report for a future crashy launch.
Duplicate protection is two independent records — a per-crash marker file and an append-style ledger of submitted ids — checked before any prompt. Cleanup after a successful send is compare-and-swap: the pending file is deleted only if it still holds the same crash id (a newer crash may have replaced it), and if deletion fails, the file is overwritten with a "submitted" tombstone so the next load discards instead of re-prompting.
Manual bug reports
The Report button in the home title bar opens a dialog: summary, what happened, expected behavior, optional follow-up email, and an on-by-default checkbox for technical details. Diagnostics — app name, version+build, environment, platform and OS version — are attached only when that box stays checked. Submissions go to POST /reports/bug and are acknowledged with 202. Notably absent from both payloads: logs, user id, device id, location, screenshots. The only free text is what the user typed.
Internal-testing tooling
Non-release builds with INTERNAL_TESTING set gain a log console: the bootstrap zone mirrors all print output into a 400-line ring buffer, and holding three fingers anywhere for five seconds toggles an overlay showing it (selectable monospace text, so a tester can copy lines out). The same define bypasses the attestation gate, which is what makes simulator QA possible. Release builds reject the define entirely, and the zone interceptor doesn't run at all outside internal testing — the buffer stays empty at zero cost.
| Aspect | Value / decision |
|---|---|
| Endpoints | POST /reports/bug, POST /reports/crash (202 Accepted) |
| Pending queue | Depth 1; newest crash replaces unreviewed older one |
| Crash id | crash-<UTC microseconds>; dedupe via marker file + ledger |
| State files | pending_crash_report.json, session_active.marker, submitted_crash_ids.json, per-crash markers — all atomic writes |
| Non-fatal filter | Silent · image resource service · layout overflow |
| Diagnostics | App name, version+build, environment, platform, OS version — no logs, ids, or location |
| Log console | 400-line ring · 3-finger 5 s hold · internal-testing builds only |
Known quirk: the ENV define has two different defaults in the same app — ReportingService defaults its diagnostics field to prod, while the API client defaults to dev/release. A debug build without an explicit ENV therefore reports environment: "prod" in its diagnostics.