diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90547ff1..da7f04aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,7 @@ The auto-opened promotion PRs are idempotent — only one is open per branch pai - **No third-party APIs**: no direct Ethereum JSON-RPC calls (Infura, Alchemy, public nodes, etc.), no block explorer APIs (Etherscan, …), no price feeds, no analytics endpoints, no third-party SDKs that call out over the network. - If a feature needs on-chain data (e.g. native ETH balance, transaction status, token balance), add a new endpoint to [`DFXswiss/api`](https://github.com/DFXswiss/api) and let the app call that endpoint. The API is the single gateway. - All network calls must go through `AppStore.httpClient` with `buildUri(_host, …)` — `_host` resolves to the DFX API host via `ApiConfig`. Do not instantiate `http.Client`/`Dio`/`Web3Client` against other hosts. +- **One scoped exception — crash reporting.** Builds that inject `--dart-define=SENTRY_DSN=...` deliver crash reports to the company-operated crash-reporting service ([`lib/setup/error_handling/crash_reporting.dart`](lib/setup/error_handling/crash_reporting.dart)). This is first-party infrastructure telemetry, not a third-party service: without an injected DSN (all local and test builds) the SDK never starts and produces no network traffic, and the delivered data is limited to error events — no PII, no screenshots, no performance tracing, no session telemetry (the exact pinned option surface lives in `crash_reporting.dart`). Widening what is sent (breadcrumbs with request URLs, user context, attachments) is a review-blocking change, not a config tweak. ## API as Decision Authority — CRITICAL diff --git a/lib/main.dart b/lib/main.dart index cb13b410..47c34073 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/error_handling/crash_reporting.dart'; import 'package:realunit_wallet/setup/error_handling/error_handlers.dart'; import 'package:realunit_wallet/setup/lifecycle_initializer.dart'; import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; @@ -19,10 +20,15 @@ Future main() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); installErrorHandlers(); + // Preserve before the first await: an async gap ahead of preserve() would + // let the native splash auto-dismiss and flash to blank. + if (kReleaseMode) FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + // After installErrorHandlers on purpose: the reporter chains the handlers + // installed above, while installErrorHandlers overwrites the async hook. + await initCrashReporting(); // only preserve splash screen for 3 seconds for release version if (kReleaseMode) { - FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); await _initializeWithSplashDuration(); FlutterNativeSplash.remove(); } else { diff --git a/lib/setup/error_handling/crash_reporting.dart b/lib/setup/error_handling/crash_reporting.dart new file mode 100644 index 00000000..b9aa5bac --- /dev/null +++ b/lib/setup/error_handling/crash_reporting.dart @@ -0,0 +1,75 @@ +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +/// The crash-reporting DSN, injected at build time via +/// `--dart-define=SENTRY_DSN=...`. Local and CI builds that do not inject a +/// DSN get the empty default, which keeps crash reporting fully disabled — +/// no SDK start, no network traffic. The DSN points at the company-operated +/// crash-reporting service; see CONTRIBUTING § API Access for why this is the +/// one sanctioned non-API endpoint. +const crashReportingDsn = String.fromEnvironment('SENTRY_DSN'); + +/// Reported as the Sentry environment. Only release builds carrying a DSN +/// report at all, so the default names the production environment; testnet +/// builds can override via `--dart-define=SENTRY_ENVIRONMENT=...`. +const crashReportingEnvironment = String.fromEnvironment( + 'SENTRY_ENVIRONMENT', + defaultValue: 'production', +); + +/// Matches the shape of [SentryFlutter.init] so tests can swap in a recording +/// fake without touching the native SDK. +typedef CrashReporterInit = Future Function(FlutterOptionsConfiguration configuration); + +/// Starts crash reporting when a DSN was injected at build time; a no-op +/// otherwise. Best-effort by design: a malformed DSN or a failing native +/// binding is logged and swallowed — reporting infrastructure must never keep +/// the wallet from starting. +/// +/// Must run AFTER [installErrorHandlers]: that installer overwrites +/// `PlatformDispatcher.onError` without chaining, so in the reverse order the +/// SDK's async-error hook would be silently dropped. The SDK itself chains +/// both handlers it wraps — `FlutterError.onError` captures first and then +/// delegates to the handler installed before it; `PlatformDispatcher.onError` +/// delegates first and then captures. +/// +/// @no-integration-test: the native SDK only starts in a build that injects a +/// DSN, which no test build does; the DSN gate, the option pinning and the +/// swallow-on-failure contract are covered by unit tests via the injectable +/// [init]. +Future initCrashReporting({ + String dsn = crashReportingDsn, + CrashReporterInit init = SentryFlutter.init, +}) async { + if (dsn.isEmpty) return; + try { + await init((options) => configureCrashReporting(options, dsn: dsn)); + } catch (error, stackTrace) { + developer.log( + 'crash reporting init failed: $error', + name: 'WalletApp', + error: error, + stackTrace: stackTrace, + ); + } +} + +/// Applies the pinned option set. The guarantee is exactly this list — an +/// upstream default flip outside it is not caught here: no PII, no +/// screenshots, no performance tracing, no session telemetry. Native crash +/// handling and ANR detection stay on their SDK defaults deliberately; they +/// produce precisely the error events this reporter exists for. +/// (View-hierarchy attachment also stays off by SDK default; its option is +/// experimental and deliberately not referenced here.) +@visibleForTesting +void configureCrashReporting(SentryFlutterOptions options, {required String dsn}) { + options + ..dsn = dsn + ..environment = crashReportingEnvironment + ..sendDefaultPii = false + ..attachScreenshot = false + ..enableAutoSessionTracking = false + ..tracesSampleRate = null; +} diff --git a/lib/setup/error_handling/error_handlers.dart b/lib/setup/error_handling/error_handlers.dart index a38ae744..87423a68 100644 --- a/lib/setup/error_handling/error_handlers.dart +++ b/lib/setup/error_handling/error_handlers.dart @@ -27,9 +27,9 @@ import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart'; /// engine's own reporting running instead of suppressing it (returning `true` /// would claim the error as handled while our log is a no-op, i.e. total /// silence). So this makes async errors *reachable where a developer is already -/// attached*; it adds no release-mode visibility on its own. Getting evidence -/// off a customer's device needs a crash reporter or a persisted log sink — -/// this handler body is the hook such a sink plugs into. +/// attached*; it adds no release-mode visibility on its own. Release-mode +/// evidence comes from the crash reporter (`crash_reporting.dart`), which is +/// initialized after this call and chains the handlers installed here. /// /// @no-integration-test: the engine-side fallback reporting that runs after /// [PlatformDispatcher.onError] returns false is embedder behaviour and is not diff --git a/pubspec.lock b/pubspec.lock index a6d39b08..a68f0b59 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -823,6 +823,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 + url: "https://pub.dev" + source: hosted + version: "0.14.2" js: dependency: transitive description: @@ -1087,6 +1095,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -1271,6 +1295,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + sentry: + dependency: transitive + description: + name: sentry + sha256: a84bf3a83b3ce1c89fce28b9f97659e3826df14a73a256952465aac2c5efdf5a + url: "https://pub.dev" + source: hosted + version: "9.25.0" sentry_dart_plugin: dependency: "direct dev" description: @@ -1279,6 +1311,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: c85575266d91f57364e9b4cb522835156ec3c5581c78339dc7fae491cd0c6f76 + url: "https://pub.dev" + source: hosted + version: "9.25.0" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0a22ecbc..775cd343 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: path_provider: 2.1.5 pointycastle: ^3.9.1 qr_flutter: ^4.1.0 + sentry_flutter: ^9.25.0 shared_preferences: ^2.5.2 sqlite3: ^3.3.0 url_launcher: ^6.3.1 diff --git a/test/setup/error_handling/crash_reporting_test.dart b/test/setup/error_handling/crash_reporting_test.dart new file mode 100644 index 00000000..b1aa3a1b --- /dev/null +++ b/test/setup/error_handling/crash_reporting_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:realunit_wallet/setup/error_handling/crash_reporting.dart'; + +void main() { + group('initCrashReporting', () { + test('stays a no-op when no DSN is injected', () async { + var initCalls = 0; + + await initCrashReporting( + dsn: '', + init: (_) async => initCalls++, + ); + + expect(initCalls, 0); + }); + + test('starts the reporter exactly once when a DSN is injected', () async { + var initCalls = 0; + + await initCrashReporting( + dsn: 'https://key@reporting.invalid/1', + init: (_) async => initCalls++, + ); + + expect(initCalls, 1); + }); + + test('applies the pinned option set', () async { + final options = SentryFlutterOptions(); + late FlutterOptionsConfiguration configuration; + + await initCrashReporting( + dsn: 'https://key@reporting.invalid/1', + init: (config) async => configuration = config, + ); + await configuration(options); + + expect(options.dsn, 'https://key@reporting.invalid/1'); + expect(options.environment, crashReportingEnvironment); + expect(options.sendDefaultPii, isFalse); + expect(options.attachScreenshot, isFalse); + expect(options.enableAutoSessionTracking, isFalse); + expect(options.tracesSampleRate, isNull); + }); + + test('swallows a failing reporter init instead of blocking startup', () async { + await expectLater( + initCrashReporting( + dsn: 'https://key@reporting.invalid/1', + init: (_) async => throw StateError('native binding unavailable'), + ), + completes, + ); + }); + }); +}