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:
111
lib/src/crash/crash_reporter.dart
Normal file
111
lib/src/crash/crash_reporter.dart
Normal 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}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user