V3-12: crash reporting (code only)

shouldInitializeCrashReporting() and scrubExtra() are pure, fully unit-tested decision/scrubbing functions wired into sentry_flutter via beforeSend/beforeBreadcrumb. Config.crashReportingEnabled defaults off; the DSN is a compile-time --dart-define, not a preference. RecordingEngine gained an injected onUnexpectedStop callback, firing once when restoreAfterProcessDeath finds a trip still 'recording' at launch -- the process died without a user stop.

Marked partially done: the ticket's real acceptance criteria (a forced crash landing in a real Sentry project from a release build, inspecting a real payload) need a Sentry account and a release build this environment can't produce.
This commit is contained in:
2026-08-17 19:32:54 -05:00
parent 0e605ef174
commit be50448917
14 changed files with 443 additions and 9 deletions

View File

@@ -0,0 +1,111 @@
/// V3-12: know when the app dies mid-ride, without turning a location app's own crash
/// reports into a second location app.
///
/// **A crash reporter in a location app is a privacy surface.** Every payload passes
/// through [scrubExtra] before it leaves the device; nothing here is a matter of
/// configuring Sentry correctly and hoping the SDK does the right thing by default.
library;
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../config/config.dart';
/// The decision of *whether* to talk to Sentry at all, pulled out as a pure function so
/// every combination of debug/release, the user's toggle, and a configured DSN can be
/// asserted without ever constructing a real client -- see
/// "Reporting disabled means the client is never initialised" in the ticket's Tests
/// section.
bool shouldInitializeCrashReporting({
required bool enabled,
required bool isDebug,
required String dsn,
}) => enabled && !isDebug && dsn.isNotEmpty;
/// Keys that must never leave the device in a crash payload: coordinates, in any of the
/// spellings this codebase or its dependencies use, plus device/ride identity. Matched
/// case-insensitively and as a substring, so `latitude`, `startLat`, `lat`, and a nested
/// `gps.lon` are all caught without having to enumerate every call site that might one
/// day capture one into `extra` or a breadcrumb.
const _forbiddenKeyFragments = [
'lat',
'lon',
'coord',
'altitude',
'device_id',
'deviceid',
'trip_id',
'tripid',
];
bool _isForbiddenKey(String key) {
final lower = key.toLowerCase();
return _forbiddenKeyFragments.any(lower.contains);
}
/// Strips forbidden keys from a breadcrumb's or event's free-form data map. Never
/// mutates [data]; returns a new map (or the same empty-ness) so a caller can never
/// accidentally hang onto the unscrubbed original by reference.
Map<String, dynamic>? scrubExtra(Map<String, dynamic>? data) {
if (data == null) return null;
return {
for (final entry in data.entries)
if (!_isForbiddenKey(entry.key)) entry.key: entry.value,
};
}
SentryEvent _scrubEvent(SentryEvent event) => event.copyWith(
// `extra` is deprecated in favour of structured contexts, but still populated by
// some integrations and manual capture calls -- scrubbed defensively regardless of
// which path an event arrived through.
// ignore: deprecated_member_use
extra: scrubExtra(event.extra),
breadcrumbs: event.breadcrumbs
?.map((b) => b.copyWith(data: scrubExtra(b.data)))
.toList(),
);
/// Wires [SentryFlutter.init] behind [shouldInitializeCrashReporting]. `dsn` is a
/// compile-time value (`--dart-define=SENTRY_DSN=...`), not a user preference -- only
/// *whether to report at all* is a user preference (see [Config.crashReportingEnabled]).
Future<void> maybeInitCrashReporting({
required Config config,
required Future<void> Function() appRunner,
String dsn = const String.fromEnvironment('SENTRY_DSN'),
bool isDebug = kDebugMode,
}) async {
if (!shouldInitializeCrashReporting(
enabled: config.crashReportingEnabled,
isDebug: isDebug,
dsn: dsn,
)) {
await appRunner();
return;
}
await SentryFlutter.init((options) {
options.dsn = dsn;
// Location, id and ride content scrubbing -- the whole point of this file.
options.beforeSend = (event, hint) async => _scrubEvent(event);
options.beforeBreadcrumb = (breadcrumb, hint) =>
breadcrumb?.copyWith(data: scrubExtra(breadcrumb.data));
// No default integrations that might capture more device context than intended.
options.sendDefaultPii = false;
}, appRunner: appRunner);
}
/// The one custom event worth having beyond crashes: recording stopped without the rider
/// choosing to stop it. That is the failure this app exists to avoid, and it can happen
/// without ever throwing -- a location permission revoked mid-ride, a killed process that
/// restores into a state the engine treats as already-stopped, or a platform quietly
/// tearing down the position stream.
///
/// A no-op when reporting isn't initialised, exactly like every Sentry call is when
/// `Sentry.isEnabled` is false -- there is no separate gate to keep in sync here.
void reportUnexpectedRecordingStop({required String reason}) {
Sentry.captureMessage(
'Recording ended unexpectedly',
level: SentryLevel.warning,
withScope: (scope) => scope.setContexts('stop', {'reason': reason}),
);
}