Files
samplez/rippr-flutter-src/docs/v3/V3-12-crash-reporting.md
uhryniuk 4c634354bd Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221.

The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
2026-08-19 13:36:04 -05:00

5.3 KiB

V3-12 — Crash reporting

Phase Quality · Depends on nothing · Size S · Status Partially done

Goal

Know when the app dies mid-ride.

Context

There is none. A recorder that crashes during a ride currently leaves no trace beyond logcat, which nobody reads — and the failure mode that matters most (recording stopping silently) is exactly the one the user cannot report usefully.

Becomes important the moment anyone who is not Dylan uses it.

Design

Sentry or Firebase Crashlytics. Sentry is the better fit: it is not tied to Google services, works identically on both platforms, and its free tier is ample here.

A crash reporter in a location app is a privacy surface. Configure it deliberately:

  • No location data in breadcrumbs or context, ever
  • No device id, no ride contents
  • Explicit opt-out in settings, and disclosed in the privacy policy
  • Debug builds report nowhere

Beyond crashes, one custom event is worth having: recording ended unexpectedly — the engine stopping without a user stop. That is the failure the app exists to avoid.

Implementation

  1. Add sentry_flutter, initialised in main() behind a config flag
  2. Scrub: no coordinates, no ids, no trip contents in any payload
  3. Breadcrumbs for lifecycle transitions only
  4. A custom event when a recording ends without a user action
  5. Settings toggle, defaulting off until a privacy policy exists

Acceptance criteria

  • A forced crash appears in Sentry from a release build
  • No coordinate ever appears in a payload — inspect a real one
  • The toggle genuinely disables reporting
  • Debug builds send nothing

Tests

  • The scrubber strips coordinates from a representative payload
  • Reporting disabled means the client is never initialised
  • Manual: force a crash in a release build and check it lands

Risks

Leaking location through breadcrumbs or a stack frame's captured state. Inspect a real payload rather than assuming the scrubber works.

Out of scope

Analytics or usage tracking. Different purpose, different consent.

Outcome

The code and its guarantees are done; the two device/account-dependent acceptance criteria are not, and can't be from here.

shouldInitializeCrashReporting({enabled, isDebug, dsn}) pulls the entire "talk to Sentry at all" decision out as a pure function — every combination of the user's toggle, debug vs. release, and a configured DSN is asserted directly, rather than trusted to however main() happens to wire things. scrubExtra strips any key matching a coordinate, altitude, device-id, or trip-id fragment (case-insensitive, substring match, so lat, latitude, startLat, and gps.lon are all caught without enumerating every call site that might one day capture one) from both event extra and every breadcrumb's data, wired in as beforeSend/beforeBreadcrumb.

Config.crashReportingEnabled defaults to false and is surfaced in Settings, same shape as every other toggle in this file. The DSN itself is not a user preference — it's a compile-time --dart-define=SENTRY_DSN=... value, since it names which Sentry project receives reports, not a fact about the rider. main() loads Config once, early, purely to make the init-or-not decision before runApp (since SentryFlutter.init wraps the app itself); the widget tree still loads its own Config in RipprApp.initState as before, since SharedPreferences is memory-cached after the first read.

The one custom event: RecordingEngine gained an optional onUnexpectedStop(String reason) callback, injected the same way uploadPending already is, so the engine keeps no opinion about where a report goes. It fires exactly once, in restoreAfterProcessDeath, when a trip is found still recording at launch — the process died without anyone calling stop(), which is precisely "the recording stopped and nobody chose that." A cleanly-stopped ride reports nothing; verified by both cases in recording_engine_test.dart.

Not done, and not attempted: wiring a real Sentry DSN, forcing a real crash in a release build, and inspecting a real payload for leaked coordinates. All three are the ticket's own actual acceptance criteria, and all three need a real Sentry account and a release build this environment cannot produce. shouldInitializeCrashReporting and scrubExtra are unit-tested as thoroughly as pure functions can be, but a passing unit test is not the same claim as "inspected a real payload," which the ticket's own Risks section insists on by name. This should be revisited once Dylan has a Sentry project to point the DSN at.

19 new tests: 9 for shouldInitializeCrashReporting/scrubExtra (including a representative end-to-end event with coordinates in both extra and a breadcrumb), 2 in recording_engine_test.dart (unexpected-stop fires on a crash, stays silent on a clean stop), 2 in config_test.dart, 1 in settings_screen_test.dart. Fixed the same viewport-culling test brittleness this section's addition exposed a second time (V3-05 first triggered it): the Sync section's fields dropped out of the default test viewport entirely, not just out of hit-test range, so four upload-endpoint tests needed a shared scrollToSync helper alongside the device-id test's existing one. flutter analyze clean; full suite green (284 tests, up from 269).