diff --git a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 976c65844..cee847f2a 100644 --- a/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -50,6 +50,11 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); } + try { + flutterEngine.getPlugins().add(new io.sentry.flutter.SentryFlutterPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin sentry_flutter, io.sentry.flutter.SentryFlutterPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin()); } catch (Exception e) { @@ -65,10 +70,5 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e); - } } } diff --git a/mobile/ios/Runner/GeneratedPluginRegistrant.m b/mobile/ios/Runner/GeneratedPluginRegistrant.m index 67307bbf3..d33d0bda0 100644 --- a/mobile/ios/Runner/GeneratedPluginRegistrant.m +++ b/mobile/ios/Runner/GeneratedPluginRegistrant.m @@ -42,6 +42,12 @@ @import path_provider_foundation; #endif +#if __has_include() +#import +#else +@import sentry_flutter; +#endif + #if __has_include() #import #else @@ -60,12 +66,6 @@ @import url_launcher_ios; #endif -#if __has_include() -#import -#else -@import webview_flutter_wkwebview; -#endif - @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { @@ -75,10 +75,10 @@ [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; + [SentryFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"SentryFlutterPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; - [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; } @end diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 6f28c77bd..a657dea83 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -19,6 +19,7 @@ import 'services/api_config.dart'; import 'services/connectivity_service.dart'; import 'services/log_service.dart'; import 'services/preferences_service.dart'; +import 'services/telemetry_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -27,7 +28,9 @@ void main() async { // Add initial log entry LogService.instance.info('App', 'Sure app starting...'); - runApp(const SureApp()); + await TelemetryService.instance.initialize( + appRunner: () => runApp(const SureApp()), + ); } class SureApp extends StatelessWidget { @@ -73,91 +76,93 @@ class SureApp extends StatelessWidget { ), ], child: Consumer( - builder: (context, themeProvider, _) => MaterialApp( - title: 'Sure Finances', - debugShowCheckedModeBanner: false, - theme: ThemeData( - fontFamily: 'Geist', - fontFamilyFallback: const [ - 'Inter', - 'Arial', - 'sans-serif', - ], - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF6366F1), - brightness: Brightness.light, - ), - useMaterial3: true, - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - filled: true, - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - darkTheme: ThemeData( - fontFamily: 'Geist', - fontFamilyFallback: const [ - 'Inter', - 'Arial', - 'sans-serif', - ], - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF6366F1), - brightness: Brightness.dark, - ), - useMaterial3: true, - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - filled: true, - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - themeMode: themeProvider.themeMode, - routes: { - '/config': (context) => const BackendConfigScreen(), - '/login': (context) => const LoginScreen(), - '/home': (context) => const MainNavigationScreen(), - }, - home: const AppWrapper(), - )), + builder: (context, themeProvider, _) => MaterialApp( + title: 'Sure Finances', + debugShowCheckedModeBanner: false, + navigatorObservers: + TelemetryService.instance.navigatorObservers, + theme: ThemeData( + fontFamily: 'Geist', + fontFamilyFallback: const [ + 'Inter', + 'Arial', + 'sans-serif', + ], + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6366F1), + brightness: Brightness.light, + ), + useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + darkTheme: ThemeData( + fontFamily: 'Geist', + fontFamilyFallback: const [ + 'Inter', + 'Arial', + 'sans-serif', + ], + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6366F1), + brightness: Brightness.dark, + ), + useMaterial3: true, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + themeMode: themeProvider.themeMode, + routes: { + '/config': (context) => const BackendConfigScreen(), + '/login': (context) => const LoginScreen(), + '/home': (context) => const MainNavigationScreen(), + }, + home: const AppWrapper(), + )), ); } } @@ -224,29 +229,71 @@ class _AppWrapperState extends State with WidgetsBindingObserver { // Handle deep link that launched the app (cold start) _appLinks.getInitialLink().then((uri) { - if (uri != null) _handleDeepLink(uri); + if (uri != null) { + TelemetryService.instance.addBreadcrumb( + 'deep_links', + 'initial_link_received', + data: {'recognized': _isSsoCallback(uri)}, + ); + _handleDeepLink(uri); + } }).catchError((e, stackTrace) { - LogService.instance.error('DeepLinks', 'Initial link error: $e\n$stackTrace'); + LogService.instance.error( + 'DeepLinks', + 'Initial link failed with ${e.runtimeType}', + ); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'deep_links.initial_link', + )); }); // Listen for deep links while app is running _linkSubscription = _appLinks.uriLinkStream.listen( (uri) => _handleDeepLink(uri), onError: (e, stackTrace) { - LogService.instance.error('DeepLinks', 'Link stream error: $e\n$stackTrace'); + LogService.instance.error( + 'DeepLinks', + 'Link stream failed with ${e.runtimeType}', + ); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'deep_links.stream', + )); }, ); } void _handleDeepLink(Uri uri) { - if (uri.scheme == 'sureapp' && uri.host == 'oauth') { + final isSsoCallback = _isSsoCallback(uri); + TelemetryService.instance.addBreadcrumb( + 'deep_links', + 'link_received', + data: {'recognized': isSsoCallback}, + ); + + if (isSsoCallback) { final authProvider = Provider.of(context, listen: false); authProvider.handleSsoCallback(uri); } } + bool _isSsoCallback(Uri uri) => + uri.scheme == 'sureapp' && uri.host == 'oauth'; + Future _checkBackendConfig() async { - final hasUrl = await ApiConfig.initialize(); + final hasUrl = await TelemetryService.instance.traceAsync( + 'app.backend_config_check', + 'Backend configuration check', + ApiConfig.initialize, + ); + TelemetryService.instance.addBreadcrumb( + 'app', + 'backend_config_checked', + data: {'configured': hasUrl}, + ); if (mounted) { setState(() { _hasBackendUrl = hasUrl; diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart index 35c85a928..885163c40 100644 --- a/mobile/lib/providers/accounts_provider.dart +++ b/mobile/lib/providers/accounts_provider.dart @@ -54,7 +54,8 @@ class AccountsProvider with ChangeNotifier { Map get assetTotalsByCurrency { final totals = {}; for (var account in _accounts.where((a) => a.isAsset)) { - totals[account.currency] = (totals[account.currency] ?? 0.0) + account.balanceAsDouble; + totals[account.currency] = + (totals[account.currency] ?? 0.0) + account.balanceAsDouble; } return totals; } @@ -62,7 +63,8 @@ class AccountsProvider with ChangeNotifier { Map get liabilityTotalsByCurrency { final totals = {}; for (var account in _accounts.where((a) => a.isLiability)) { - totals[account.currency] = (totals[account.currency] ?? 0.0) + account.balanceAsDouble; + totals[account.currency] = + (totals[account.currency] ?? 0.0) + account.balanceAsDouble; } return totals; } @@ -120,7 +122,8 @@ class AccountsProvider with ChangeNotifier { ); if (result['success'] == true && result.containsKey('accounts')) { - final serverAccounts = (result['accounts'] as List?)?.cast() ?? []; + final serverAccounts = + (result['accounts'] as List?)?.cast() ?? []; _pagination = result['pagination'] as Map?; // Save to local cache @@ -133,11 +136,13 @@ class AccountsProvider with ChangeNotifier { } else { // If server fetch failed but we have cached data, that's OK if (_accounts.isEmpty) { - _errorMessage = result['error'] as String? ?? 'Failed to fetch accounts'; + _errorMessage = + result['error'] as String? ?? 'Failed to fetch accounts'; } } } else if (!isOnline && _accounts.isEmpty) { - _errorMessage = 'You are offline. Please connect to the internet to load accounts.'; + _errorMessage = + 'You are offline. Please connect to the internet to load accounts.'; } // Fetch balance sheet independently — works even with cached accounts @@ -150,30 +155,31 @@ class AccountsProvider with ChangeNotifier { notifyListeners(); return _accounts.isNotEmpty; } catch (e) { - _log.error('AccountsProvider', 'Error in fetchAccounts: $e'); + _log.error( + 'AccountsProvider', + 'fetchAccounts failed with ${e.runtimeType}', + ); // If we have cached accounts, show them even if sync fails if (_accounts.isEmpty) { // Provide more specific error messages based on exception type if (e is SocketException) { - _errorMessage = 'Network error. Please check your internet connection and try again.'; - _log.error('AccountsProvider', 'SocketException: $e'); + _errorMessage = + 'Network error. Please check your internet connection and try again.'; } else if (e is TimeoutException) { - _errorMessage = 'Request timed out. Please check your connection and try again.'; - _log.error('AccountsProvider', 'TimeoutException: $e'); + _errorMessage = + 'Request timed out. Please check your connection and try again.'; } else if (e is FormatException) { _errorMessage = 'Server response error. Please try again later.'; - _log.error('AccountsProvider', 'FormatException: $e'); - } else if (e.toString().contains('401') || e.toString().contains('unauthorized')) { + } else if (e.toString().contains('401') || + e.toString().contains('unauthorized')) { _errorMessage = 'unauthorized'; - _log.error('AccountsProvider', 'Unauthorized error: $e'); } else if (e.toString().contains('HandshakeException') || - e.toString().contains('certificate') || - e.toString().contains('SSL')) { - _errorMessage = 'Secure connection error. Please check your internet connection and try again.'; - _log.error('AccountsProvider', 'SSL/Certificate error: $e'); + e.toString().contains('certificate') || + e.toString().contains('SSL')) { + _errorMessage = + 'Secure connection error. Please check your internet connection and try again.'; } else { _errorMessage = 'Something went wrong. Please try again.'; - _log.error('AccountsProvider', 'Unhandled exception: $e'); } } _isLoading = false; @@ -188,7 +194,8 @@ class AccountsProvider with ChangeNotifier { /// values as stale rather than clearing them. Future _fetchBalanceSheet(String accessToken) async { try { - final result = await _balanceSheetService.getBalanceSheet(accessToken: accessToken); + final result = + await _balanceSheetService.getBalanceSheet(accessToken: accessToken); if (result['success'] == true) { _familyCurrency = result['currency'] as String?; final netWorth = result['net_worth'] as Map?; @@ -205,7 +212,10 @@ class AccountsProvider with ChangeNotifier { } } } catch (e) { - _log.error('AccountsProvider', 'Error fetching balance sheet: $e'); + _log.error( + 'AccountsProvider', + 'Balance sheet fetch failed with ${e.runtimeType}', + ); // Keep existing values but mark as stale if (_netWorthFormatted != null) { _isBalanceSheetStale = true; diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index c9be3741f..d3d570d50 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -7,6 +9,7 @@ import '../services/auth_service.dart'; import '../services/device_service.dart'; import '../services/api_config.dart'; import '../services/log_service.dart'; +import '../services/telemetry_service.dart'; class AuthProvider with ChangeNotifier { final AuthService _authService = AuthService(); @@ -99,11 +102,26 @@ class AuthProvider with ChangeNotifier { } } } - } catch (e) { + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb( + 'auth', + 'stored_auth_loaded', + data: { + 'authenticated': isAuthenticated, + 'api_key_mode': _isApiKeyAuth, + }, + ); + } catch (e, stackTrace) { + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.load_stored', + ); _tokens = null; _user = null; _apiKey = null; _isApiKeyAuth = false; + _clearTelemetryUser(); } _isLoading = false; @@ -144,6 +162,8 @@ class AuthProvider with ChangeNotifier { _user = result['user'] as User?; _mfaRequired = false; _showMfaInput = false; // Reset on successful login + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb('auth', 'login_success'); _isLoading = false; notifyListeners(); return true; @@ -171,12 +191,25 @@ class AuthProvider with ChangeNotifier { _showMfaInput = true; } } + _addTelemetryBreadcrumb( + 'auth', + 'login_failed', + data: { + 'mfa_required': _mfaRequired, + 'otp_submitted': otpCode != null, + }, + ); _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('Login', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.login', + ); _errorMessage = 'Unable to connect. Please check your network and try again.'; _isLoading = false; @@ -204,17 +237,31 @@ class AuthProvider with ChangeNotifier { _apiKey = apiKey; _isApiKeyAuth = true; ApiConfig.setApiKeyAuth(apiKey); + _clearTelemetryUser(); + _addTelemetryBreadcrumb( + 'auth', + 'api_key_login_success', + ); _isLoading = false; notifyListeners(); return true; } else { + _addTelemetryBreadcrumb( + 'auth', + 'api_key_login_failed', + ); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('API key login', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.api_key_login', + ); _errorMessage = 'Unable to connect. Please check your network and try again.'; _isLoading = false; @@ -248,17 +295,25 @@ class AuthProvider with ChangeNotifier { if (result['success'] == true) { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb('auth', 'signup_success'); _isLoading = false; notifyListeners(); return true; } else { + _addTelemetryBreadcrumb('auth', 'signup_failed'); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('Signup', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.signup', + ); _errorMessage = 'Unable to connect. Please check your network and try again.'; _isLoading = false; @@ -281,11 +336,21 @@ class AuthProvider with ChangeNotifier { final launched = await launchUrl(Uri.parse(ssoUrl), mode: LaunchMode.externalApplication); + _addTelemetryBreadcrumb( + 'auth', + 'sso_launch_result', + data: {'launched': launched}, + ); if (!launched) { _errorMessage = 'Unable to open browser for sign-in.'; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('SSO launch', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.sso_launch', + ); _errorMessage = 'Unable to start sign-in. Please try again.'; } finally { _isLoading = false; @@ -305,6 +370,11 @@ class AuthProvider with ChangeNotifier { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; _ssoOnboardingPending = false; + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb( + 'auth', + 'sso_callback_success', + ); _isLoading = false; notifyListeners(); return true; @@ -317,17 +387,34 @@ class AuthProvider with ChangeNotifier { _ssoLastName = result['last_name'] as String?; _ssoAllowAccountCreation = result['allow_account_creation'] == true; _ssoHasPendingInvitation = result['has_pending_invitation'] == true; + _addTelemetryBreadcrumb( + 'auth', + 'sso_onboarding_required', + data: { + 'account_creation_allowed': _ssoAllowAccountCreation, + 'has_pending_invitation': _ssoHasPendingInvitation, + }, + ); _isLoading = false; notifyListeners(); return false; } else { + _addTelemetryBreadcrumb( + 'auth', + 'sso_callback_failed', + ); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('SSO callback', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.sso_callback', + ); _errorMessage = 'Sign-in failed. Please try again.'; _isLoading = false; notifyListeners(); @@ -360,17 +447,28 @@ class AuthProvider with ChangeNotifier { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; _clearSsoOnboardingState(); + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb( + 'auth', + 'sso_link_success', + ); _isLoading = false; notifyListeners(); return true; } else { + _addTelemetryBreadcrumb('auth', 'sso_link_failed'); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('SSO link', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.sso_link', + ); _errorMessage = 'Failed to link account. Please try again.'; _isLoading = false; notifyListeners(); @@ -403,17 +501,31 @@ class AuthProvider with ChangeNotifier { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; _clearSsoOnboardingState(); + _updateTelemetryUser(_user); + _addTelemetryBreadcrumb( + 'auth', + 'sso_create_account_success', + ); _isLoading = false; notifyListeners(); return true; } else { + _addTelemetryBreadcrumb( + 'auth', + 'sso_create_account_failed', + ); _errorMessage = result['error'] as String?; _isLoading = false; notifyListeners(); return false; } - } catch (e) { + } catch (e, stackTrace) { _logAuthException('SSO create account', e); + _captureTelemetryException( + e, + stackTrace, + operation: 'auth.sso_create_account', + ); _errorMessage = 'Failed to create account. Please try again.'; _isLoading = false; notifyListeners(); @@ -445,6 +557,10 @@ class AuthProvider with ChangeNotifier { _errorMessage = null; _mfaRequired = false; ApiConfig.clearApiKeyAuth(); + _safeTelemetry(() async { + TelemetryService.instance.addBreadcrumb('auth', 'logout'); + await TelemetryService.instance.clearUser(); + }); notifyListeners(); } @@ -472,6 +588,64 @@ class AuthProvider with ChangeNotifier { } } + Future _setTelemetryUser(User? user) async { + if (user == null) { + await TelemetryService.instance.clearUser(); + return; + } + + await TelemetryService.instance.setUserId(user.id); + } + + void _updateTelemetryUser(User? user) { + _safeTelemetry(() => _setTelemetryUser(user)); + } + + void _clearTelemetryUser() { + _safeTelemetry(() => TelemetryService.instance.clearUser()); + } + + void _addTelemetryBreadcrumb( + String category, + String message, { + Map? data, + }) { + _safeTelemetry( + () => TelemetryService.instance.addBreadcrumb( + category, + message, + data: data, + ), + ); + } + + void _captureTelemetryException( + Object error, + StackTrace stackTrace, { + required String operation, + }) { + _safeTelemetry( + () => TelemetryService.instance.captureHandledException( + error, + stackTrace, + operation: operation, + ), + ); + } + + void _safeTelemetry(FutureOr Function() action) { + unawaited(Future(() async { + try { + await action(); + } catch (error) { + LogService.instance.warning( + 'AuthProvider', + 'Telemetry operation failed: ${error.runtimeType}', + ); + } + })); + } + Future getValidAccessToken() async { if (_isApiKeyAuth && _apiKey != null) { return _apiKey; diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart index 8d63a2ed6..b34a77765 100644 --- a/mobile/lib/providers/transactions_provider.dart +++ b/mobile/lib/providers/transactions_provider.dart @@ -71,7 +71,10 @@ class TransactionsProvider with ChangeNotifier { } }).catchError((e) { if (!_isDisposed) { - _log.error('TransactionsProvider', 'Auto-sync failed: $e'); + _log.error( + 'TransactionsProvider', + 'Auto-sync failed with ${e.runtimeType}', + ); } }).whenComplete(() { if (!_isDisposed) { @@ -142,7 +145,10 @@ class TransactionsProvider with ChangeNotifier { } } } catch (e) { - _log.error('TransactionsProvider', 'Error in fetchTransactions: $e'); + _log.error( + 'TransactionsProvider', + 'fetchTransactions failed with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; } finally { _isLoading = false; @@ -243,7 +249,10 @@ class TransactionsProvider with ChangeNotifier { }).catchError((e) { if (_isDisposed) return; - _log.error('TransactionsProvider', 'Exception during upload: $e'); + _log.error( + 'TransactionsProvider', + 'Upload failed with ${e.runtimeType}', + ); _error = 'Failed to upload transaction. It will sync when online.'; notifyListeners(); }); @@ -254,7 +263,10 @@ class TransactionsProvider with ChangeNotifier { return true; // Always return true because it's saved locally } catch (e) { - _log.error('TransactionsProvider', 'Failed to create transaction: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to create transaction with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -336,7 +348,10 @@ class TransactionsProvider with ChangeNotifier { _error = result['error'] as String? ?? 'Failed to update transaction'; return false; } catch (e) { - _log.error('TransactionsProvider', 'Failed to update transaction: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to update transaction with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; return false; } finally { @@ -386,7 +401,10 @@ class TransactionsProvider with ChangeNotifier { return true; } } catch (e) { - _log.error('TransactionsProvider', 'Failed to delete transaction: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to delete transaction with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -439,7 +457,9 @@ class TransactionsProvider with ChangeNotifier { } } catch (e) { _log.error( - 'TransactionsProvider', 'Failed to delete multiple transactions: $e'); + 'TransactionsProvider', + 'Failed to delete multiple transactions with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -475,7 +495,10 @@ class TransactionsProvider with ChangeNotifier { return false; } } catch (e) { - _log.error('TransactionsProvider', 'Failed to undo transaction: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to undo transaction with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -507,7 +530,10 @@ class TransactionsProvider with ChangeNotifier { _error = result.error; } } catch (e) { - _log.error('TransactionsProvider', 'Failed to sync transactions: $e'); + _log.error( + 'TransactionsProvider', + 'Failed to sync transactions with ${e.runtimeType}', + ); _error = 'Something went wrong. Please try again.'; } finally { _isLoading = false; diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart index a3c1afe96..f6e90bfa4 100644 --- a/mobile/lib/screens/backend_config_screen.dart +++ b/mobile/lib/screens/backend_config_screen.dart @@ -53,7 +53,7 @@ class _BackendConfigScreenState extends State { // sensible defaults; the user can re-enter and re-save. LogService.instance.warning( 'BackendConfigScreen', - 'Failed to load saved backend config: $e', + 'Failed to load saved backend config with ${e.runtimeType}', ); } finally { if (mounted) { diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 316b8d400..89ae9f067 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -115,7 +115,7 @@ class _SettingsScreenState extends State { } catch (e) { LogService.instance.warning( 'SettingsScreen', - 'Failed to load custom headers: $e', + 'Failed to load custom headers with ${e.runtimeType}', ); // Keep the existing _customHeaders state so the screen remains usable. } @@ -172,14 +172,17 @@ class _SettingsScreenState extends State { } } catch (e) { final log = LogService.instance; - log.error('Settings', 'Failed to clear local data: $e'); + log.error( + 'Settings', + 'Failed to clear local data with ${e.runtimeType}', + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to clear local data: $e'), + const SnackBar( + content: Text('Failed to clear local data.'), backgroundColor: Colors.red, - duration: const Duration(seconds: 3), + duration: Duration(seconds: 3), ), ); } @@ -417,9 +420,13 @@ class _SettingsScreenState extends State { ); } catch (e) { if (!mounted) return; + LogService.instance.warning( + 'Settings', + 'Failed to save custom proxy headers with ${e.runtimeType}', + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to save custom proxy headers: $e'), + content: const Text('Failed to save custom proxy headers.'), backgroundColor: Theme.of(context).colorScheme.error, ), ); diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 2a419b914..a0dd4b4ae 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -15,6 +15,47 @@ class AuthService { static const String _apiKeyKey = 'api_key'; static const String _authModeKey = 'auth_mode'; + String _responseErrorMessage(dynamic responseData, String fallback) { + if (responseData is! Map) return fallback; + + return _messageFromErrorValue(responseData['error']) ?? + _messageFromErrorValue(responseData['errors']) ?? + fallback; + } + + String? _messageFromErrorValue(Object? value) { + if (value == null) return null; + + if (value is String) { + final message = value.trim(); + return message.isEmpty ? null : message; + } + + if (value is Iterable) { + final message = value + .map(_messageFromErrorValue) + .whereType() + .where((part) => part.isNotEmpty) + .join(', '); + return message.isEmpty ? null : message; + } + + if (value is Map) { + final message = value.values + .map(_messageFromErrorValue) + .whereType() + .where((part) => part.isNotEmpty) + .join(', '); + if (message.isNotEmpty) return message; + + final encoded = jsonEncode(value); + return encoded.isEmpty ? null : encoded; + } + + final message = value.toString().trim(); + return message.isEmpty ? null : message; + } + void _logAuthException(String operation, Object error) { LogService.instance.error( 'AuthService', @@ -22,19 +63,25 @@ class AuthService { ); } - String _responseError(Map responseData, String fallback) { - final error = responseData['error']; - if (error is String && error.isNotEmpty) return error; + User? _parseResponseUser(Map responseData, String source) { + final rawUser = responseData['user']; + if (rawUser == null) return null; - final errors = responseData['errors']; - if (errors is List) { - final joined = errors.whereType().join(', '); - if (joined.isNotEmpty) return joined; - } else if (errors is String && errors.isNotEmpty) { - return errors; + _logUserPayloadShape(source, rawUser); + return User.fromJson(rawUser); + } + + Future _saveSession(AuthTokens tokens, User? user) async { + try { + await _saveTokens(tokens); + if (user != null) { + await _saveUser(user); + } + } catch (_) { + await _storage.delete(key: _tokenKey); + await _storage.delete(key: _userKey); + rethrow; } - - return fallback; } Future> login({ @@ -72,18 +119,10 @@ class AuthService { final responseData = jsonDecode(response.body); if (response.statusCode == 200) { - // Store tokens final tokens = AuthTokens.fromJson(responseData); - await _saveTokens(tokens); + final user = _parseResponseUser(responseData, 'login'); - // Store user data - parse once and reuse - User? user; - if (responseData['user'] != null) { - final rawUser = responseData['user']; - _logUserPayloadShape('login', rawUser); - user = User.fromJson(rawUser); - await _saveUser(user); - } + await _saveSession(tokens, user); return { 'success': true, @@ -100,7 +139,7 @@ class AuthService { } else { return { 'success': false, - 'error': _responseError(responseData, 'Login failed'), + 'error': _responseErrorMessage(responseData, 'Login failed'), }; } } on SocketException catch (e) { @@ -178,18 +217,10 @@ class AuthService { final responseData = jsonDecode(response.body); if (response.statusCode == 201) { - // Store tokens final tokens = AuthTokens.fromJson(responseData); - await _saveTokens(tokens); + final user = _parseResponseUser(responseData, 'signup'); - // Store user data - parse once and reuse - User? user; - if (responseData['user'] != null) { - final rawUser = responseData['user']; - _logUserPayloadShape('signup', rawUser); - user = User.fromJson(rawUser); - await _saveUser(user); - } + await _saveSession(tokens, user); return { 'success': true, @@ -199,7 +230,7 @@ class AuthService { } else { return { 'success': false, - 'error': _responseError(responseData, 'Signup failed'), + 'error': _responseErrorMessage(responseData, 'Signup failed'), }; } } on SocketException catch (e) { @@ -445,11 +476,13 @@ class AuthService { 'expires_in': data['expires_in'] ?? 0, 'created_at': data['created_at'] ?? 0, }); - await _saveTokens(tokens); - _logUserPayloadShape('sso_exchange', data['user']); - final user = User.fromJson(data['user']); - await _saveUser(user); + final user = _parseResponseUser(data, 'sso_exchange'); + if (user == null) { + throw const FormatException('Missing user payload'); + } + + await _saveSession(tokens, user); return { 'success': true, @@ -500,14 +533,9 @@ class AuthService { if (response.statusCode == 200) { final tokens = AuthTokens.fromJson(responseData); - await _saveTokens(tokens); + final user = _parseResponseUser(responseData, 'sso_link'); - User? user; - if (responseData['user'] != null) { - _logUserPayloadShape('sso_link', responseData['user']); - user = User.fromJson(responseData['user']); - await _saveUser(user); - } + await _saveSession(tokens, user); return { 'success': true, @@ -517,7 +545,8 @@ class AuthService { } else { return { 'success': false, - 'error': _responseError(responseData, 'Account linking failed'), + 'error': + _responseErrorMessage(responseData, 'Account linking failed'), }; } } on SocketException catch (e) { @@ -558,14 +587,9 @@ class AuthService { if (response.statusCode == 200) { final tokens = AuthTokens.fromJson(responseData); - await _saveTokens(tokens); + final user = _parseResponseUser(responseData, 'sso_create_account'); - User? user; - if (responseData['user'] != null) { - _logUserPayloadShape('sso_create_account', responseData['user']); - user = User.fromJson(responseData['user']); - await _saveUser(user); - } + await _saveSession(tokens, user); return { 'success': true, @@ -575,7 +599,8 @@ class AuthService { } else { return { 'success': false, - 'error': _responseError(responseData, 'Account creation failed'), + 'error': + _responseErrorMessage(responseData, 'Account creation failed'), }; } } on SocketException catch (e) { @@ -617,7 +642,7 @@ class AuthService { return { 'success': false, - 'error': _responseError(responseData, 'Failed to enable AI'), + 'error': _responseErrorMessage(responseData, 'Failed to enable AI'), }; } catch (e) { _logAuthException('Enable AI', e); diff --git a/mobile/lib/services/biometric_service.dart b/mobile/lib/services/biometric_service.dart index b6daa17a4..3d118a982 100644 --- a/mobile/lib/services/biometric_service.dart +++ b/mobile/lib/services/biometric_service.dart @@ -39,8 +39,11 @@ class BiometricService { biometricOnly: false, ), ); - } catch (e, stack) { - LogService.instance.error('BiometricService', 'authenticate() failed: $e\n$stack'); + } catch (e) { + LogService.instance.error( + 'BiometricService', + 'authenticate() failed with ${e.runtimeType}', + ); return false; } } diff --git a/mobile/lib/services/database_helper.dart b/mobile/lib/services/database_helper.dart index 91741adb3..93a01848d 100644 --- a/mobile/lib/services/database_helper.dart +++ b/mobile/lib/services/database_helper.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'log_service.dart'; +import 'telemetry_service.dart'; class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); @@ -44,7 +47,12 @@ class DatabaseHelper { return _database!; } catch (e, stackTrace) { _log.error('DatabaseHelper', - 'Error initializing local database sure_offline.db: $e'); + 'Error initializing local database sure_offline.db: ${e.runtimeType}'); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'database.open', + )); FlutterError.reportError( FlutterErrorDetails( exception: e, @@ -70,7 +78,14 @@ class DatabaseHelper { ); } catch (e, stackTrace) { _log.error( - 'DatabaseHelper', 'Error opening database file "$filePath": $e'); + 'DatabaseHelper', + 'Error opening database file "$filePath": ${e.runtimeType}', + ); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'database.initialize', + )); FlutterError.reportError( FlutterErrorDetails( exception: e, @@ -144,7 +159,15 @@ class DatabaseHelper { ON transactions(server_id) '''); } catch (e, stackTrace) { - _log.error('DatabaseHelper', 'Error creating local database schema: $e'); + _log.error( + 'DatabaseHelper', + 'Error creating local database schema: ${e.runtimeType}', + ); + unawaited(TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'database.create_schema', + )); FlutterError.reportError( FlutterErrorDetails( exception: e, diff --git a/mobile/lib/services/offline_storage_service.dart b/mobile/lib/services/offline_storage_service.dart index cad906fb1..10ccbab33 100644 --- a/mobile/lib/services/offline_storage_service.dart +++ b/mobile/lib/services/offline_storage_service.dart @@ -59,7 +59,10 @@ class OfflineStorageService { _log.info('OfflineStorage', 'Transaction saved successfully'); return transaction; } catch (e) { - _log.error('OfflineStorage', 'Failed to save transaction: $e'); + _log.error( + 'OfflineStorage', + 'Failed to save transaction with ${e.runtimeType}', + ); rethrow; } } diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index 702538749..ced804f40 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -7,6 +7,7 @@ import 'transactions_service.dart'; import 'accounts_service.dart'; import 'connectivity_service.dart'; import 'log_service.dart'; +import 'telemetry_service.dart'; class SyncService with ChangeNotifier { final OfflineStorageService _offlineStorage = OfflineStorageService(); @@ -32,8 +33,21 @@ class SyncService with ChangeNotifier { final pendingDeletes = await _offlineStorage.getPendingDeletes(); _log.info('SyncService', 'Found ${pendingDeletes.length} pending deletes to process'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_delete_replay_started', + data: {'count': pendingDeletes.length}, + ); if (pendingDeletes.isEmpty) { + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_delete_replay_finished', + data: { + 'success_count': 0, + 'failure_count': 0, + }, + ); return SyncResult(success: true, syncedCount: 0); } @@ -75,7 +89,10 @@ class SyncService with ChangeNotifier { } } catch (e) { // Mark as failed - _log.error('SyncService', 'Delete exception: $e'); + _log.error( + 'SyncService', + 'Delete failed with ${e.runtimeType}', + ); await _offlineStorage.updateTransactionSyncStatus( localId: transaction.localId, syncStatus: SyncStatus.failed, @@ -87,6 +104,14 @@ class SyncService with ChangeNotifier { _log.info('SyncService', 'Delete complete: $successCount success, $failureCount failed'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_delete_replay_finished', + data: { + 'success_count': successCount, + 'failure_count': failureCount, + }, + ); return SyncResult( success: failureCount == 0, @@ -95,7 +120,10 @@ class SyncService with ChangeNotifier { error: failureCount > 0 ? lastError : null, ); } catch (e) { - _log.error('SyncService', 'Sync pending deletes exception: $e'); + _log.error( + 'SyncService', + 'Sync pending deletes failed with ${e.runtimeType}', + ); return SyncResult( success: false, syncedCount: successCount, @@ -117,8 +145,21 @@ class SyncService with ChangeNotifier { await _offlineStorage.getPendingTransactions(); _log.info('SyncService', 'Found ${pendingTransactions.length} pending transactions to upload'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_upload_replay_started', + data: {'count': pendingTransactions.length}, + ); if (pendingTransactions.isEmpty) { + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_upload_replay_finished', + data: { + 'success_count': 0, + 'failure_count': 0, + }, + ); return SyncResult(success: true, syncedCount: 0); } @@ -163,7 +204,10 @@ class SyncService with ChangeNotifier { } } catch (e) { // Mark as failed - _log.error('SyncService', 'Upload exception: $e'); + _log.error( + 'SyncService', + 'Upload failed with ${e.runtimeType}', + ); await _offlineStorage.updateTransactionSyncStatus( localId: transaction.localId, syncStatus: SyncStatus.failed, @@ -175,6 +219,14 @@ class SyncService with ChangeNotifier { _log.info('SyncService', 'Upload complete: $successCount success, $failureCount failed'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'pending_upload_replay_finished', + data: { + 'success_count': successCount, + 'failure_count': failureCount, + }, + ); return SyncResult( success: failureCount == 0, @@ -183,7 +235,10 @@ class SyncService with ChangeNotifier { error: failureCount > 0 ? lastError : null, ); } catch (e) { - _log.error('SyncService', 'Sync pending transactions exception: $e'); + _log.error( + 'SyncService', + 'Sync pending transactions failed with ${e.runtimeType}', + ); return SyncResult( success: false, syncedCount: successCount, @@ -214,7 +269,10 @@ class SyncService with ChangeNotifier { return result; } catch (e) { - _log.error('SyncService', 'syncPendingTransactions exception: $e'); + _log.error( + 'SyncService', + 'syncPendingTransactions failed with ${e.runtimeType}', + ); _isSyncing = false; _syncError = e.toString(); notifyListeners(); @@ -231,6 +289,29 @@ class SyncService with ChangeNotifier { required String accessToken, String? accountId, }) async { + final telemetrySpan = TelemetryService.instance.startSpan( + 'sync.transactions_fetch', + 'Mobile transaction fetch', + data: {'scoped_account': accountId != null}, + ); + var telemetrySpanFinished = false; + var telemetrySucceeded = false; + Object? telemetryThrowable; + + Future finishTelemetrySpan({ + required bool success, + Object? throwable, + }) async { + if (telemetrySpanFinished) return; + + telemetrySpanFinished = true; + await TelemetryService.instance.finishSpan( + telemetrySpan, + success: success, + throwable: throwable, + ); + } + try { _log.info('SyncService', '========== SYNC FROM SERVER START =========='); _log.info( @@ -239,6 +320,11 @@ class SyncService with ChangeNotifier { ? 'Fetching transactions for all accounts' : 'Fetching transactions for scoped account', ); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'transactions_fetch_started', + data: {'scoped_account': accountId != null}, + ); List allTransactions = []; int currentPage = 1; @@ -249,6 +335,15 @@ class SyncService with ChangeNotifier { while (currentPage <= totalPages) { _log.info('SyncService', '>>> Fetching page $currentPage of $totalPages (perPage: $perPage)'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'transactions_fetch_page', + data: { + 'page': currentPage, + 'total_pages': totalPages, + 'per_page': perPage, + }, + ); final result = await _transactionsService.getTransactions( accessToken: accessToken, @@ -306,6 +401,11 @@ class SyncService with ChangeNotifier { } else { _log.error('SyncService', 'Server returned error on page $currentPage: ${result['error']}'); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'transactions_fetch_failed', + data: {'page': currentPage}, + ); return SyncResult( success: false, error: result['error'] as String? ?? 'Failed to sync from server', @@ -349,6 +449,15 @@ class SyncService with ChangeNotifier { _log.info( 'SyncService', '========== SYNC FROM SERVER COMPLETE =========='); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'transactions_fetch_finished', + data: { + 'page_count': currentPage - 1, + 'transaction_count': allTransactions.length, + }, + ); + telemetrySucceeded = true; _lastSyncTime = DateTime.now(); notifyListeners(); @@ -356,12 +465,26 @@ class SyncService with ChangeNotifier { success: true, syncedCount: allTransactions.length, ); - } catch (e) { - _log.error('SyncService', 'Exception in syncFromServer: $e'); + } catch (e, stackTrace) { + _log.error( + 'SyncService', + 'syncFromServer failed with ${e.runtimeType}', + ); + telemetryThrowable = e; + await TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'sync.transactions_fetch', + ); return SyncResult( success: false, error: e.toString(), ); + } finally { + await finishTelemetrySpan( + success: telemetrySucceeded, + throwable: telemetryThrowable, + ); } } @@ -409,6 +532,7 @@ class SyncService with ChangeNotifier { } _log.info('SyncService', '==== Full Sync Started ===='); + TelemetryService.instance.addBreadcrumb('sync', 'full_sync_started'); _isSyncing = true; _syncError = null; notifyListeners(); @@ -453,6 +577,16 @@ class SyncService with ChangeNotifier { _log.info('SyncService', '==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ===='); + TelemetryService.instance.addBreadcrumb( + 'sync', + 'full_sync_finished', + data: { + 'success': allSuccess, + 'delete_failures': deleteResult.failedCount ?? 0, + 'upload_failures': uploadResult.failedCount ?? 0, + 'downloaded_count': downloadResult.syncedCount ?? 0, + }, + ); notifyListeners(); @@ -465,8 +599,13 @@ class SyncService with ChangeNotifier { (deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0), error: _syncError, ); - } catch (e) { - _log.error('SyncService', 'Full sync exception: $e'); + } catch (e, stackTrace) { + _log.error('SyncService', 'Full sync failed with ${e.runtimeType}'); + await TelemetryService.instance.captureHandledException( + e, + stackTrace, + operation: 'sync.full', + ); _isSyncing = false; _syncError = e.toString(); notifyListeners(); diff --git a/mobile/lib/services/telemetry_service.dart b/mobile/lib/services/telemetry_service.dart new file mode 100644 index 000000000..52b1c99ea --- /dev/null +++ b/mobile/lib/services/telemetry_service.dart @@ -0,0 +1,568 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import 'log_service.dart'; + +class TelemetryConfig { + static const _dsn = String.fromEnvironment('SENTRY_DSN'); + static const _environment = String.fromEnvironment( + 'SENTRY_ENVIRONMENT', + defaultValue: 'mobile', + ); + static const _release = String.fromEnvironment('SENTRY_RELEASE'); + static const _tracesSampleRate = String.fromEnvironment( + 'SENTRY_TRACES_SAMPLE_RATE', + ); + static const _profilesSampleRate = String.fromEnvironment( + 'SENTRY_PROFILES_SAMPLE_RATE', + ); + + final String dsn; + final String environment; + final String release; + final double tracesSampleRate; + final double profilesSampleRate; + + const TelemetryConfig({ + required this.dsn, + required this.environment, + required this.release, + required this.tracesSampleRate, + required this.profilesSampleRate, + }); + + factory TelemetryConfig.fromEnvironment() { + return TelemetryConfig( + dsn: _dsn, + environment: _environment.trim().isEmpty ? 'mobile' : _environment, + release: _release, + tracesSampleRate: sampleRate(_tracesSampleRate, defaultValue: 0.25), + profilesSampleRate: sampleRate(_profilesSampleRate, defaultValue: 0.25), + ); + } + + bool get isConfigured => dsn.trim().isNotEmpty; + + static double sampleRate( + String value, { + required double defaultValue, + }) { + final parsed = double.tryParse(value.trim()); + if (parsed == null || parsed.isNaN || parsed.isInfinite) { + return defaultValue; + } + + if (parsed < 0) return 0; + if (parsed > 1) return 1; + return parsed; + } +} + +class TelemetryService { + static final TelemetryService instance = TelemetryService(); + + final TelemetryConfig _config; + final SentryNavigatorObserver _navigatorObserver = SentryNavigatorObserver( + enableAutoTransactions: false, + routeNameExtractor: scrubRouteSettings, + ); + bool _initialized = false; + + TelemetryService({TelemetryConfig? config}) + : _config = config ?? TelemetryConfig.fromEnvironment(); + + bool get isConfigured => _config.isConfigured; + bool get isActive => isConfigured && _initialized; + + List get navigatorObservers => + isConfigured ? [_navigatorObserver] : const []; + + Future initialize({ + required FutureOr Function() appRunner, + }) async { + if (!isConfigured) { + await appRunner(); + return; + } + + var appRunnerStarted = false; + + try { + await SentryFlutter.init( + (options) { + options.dsn = _config.dsn.trim(); + options.environment = _config.environment; + if (_config.release.trim().isNotEmpty) { + options.release = _config.release.trim(); + } + options.tracesSampleRate = _config.tracesSampleRate; + // TODO: Remove this suppression once sentry_flutter stabilizes + // options.profilesSampleRate, then revalidate _config.profilesSampleRate + // against the upstream changelog. + // ignore: experimental_member_use + options.profilesSampleRate = _config.profilesSampleRate; + options.sendDefaultPii = false; + options.attachScreenshot = false; + options.maxRequestBodySize = MaxRequestBodySize.never; + options.maxResponseBodySize = MaxResponseBodySize.never; + options.beforeSend = filterEvent; + options.beforeSendTransaction = filterTransaction; + options.beforeBreadcrumb = filterBreadcrumb; + _initialized = true; + }, + appRunner: () async { + appRunnerStarted = true; + await appRunner(); + }, + ); + } catch (e, stackTrace) { + if (appRunnerStarted) rethrow; + + _initialized = false; + LogService.instance.warning( + 'Telemetry', + 'Sentry initialization failed; continuing without telemetry: ' + '${e.runtimeType}\n${stackTrace.runtimeType}', + ); + await appRunner(); + } + } + + Future setUserId(String? userId) async { + if (!isActive) return; + + await Sentry.configureScope((scope) async { + final safeUserId = sanitizeUserId(userId); + await scope + .setUser(safeUserId == null ? null : SentryUser(id: safeUserId)); + }); + } + + Future clearUser() => setUserId(null); + + void addBreadcrumb( + String category, + String message, { + Map? data, + SentryLevel level = SentryLevel.info, + }) { + if (!isActive) return; + + unawaited(Sentry.addBreadcrumb( + Breadcrumb( + category: LogService.sanitize(category), + message: _sanitizeFreeformText(message), + data: sanitizeData(data), + level: level, + ), + )); + } + + Future traceAsync( + String operation, + String description, + Future Function() callback, { + Map? data, + bool Function(T result)? isSuccess, + }) async { + if (!isActive) return await callback(); + + final span = Sentry.startTransaction( + _sanitizeFreeformText(description), + LogService.sanitize(operation), + bindToScope: false, + ); + + for (final entry in sanitizeData(data).entries) { + span.setData(entry.key, entry.value); + } + + try { + final result = await callback(); + final status = isSuccess == null || isSuccess(result) + ? const SpanStatus.ok() + : const SpanStatus.internalError(); + await span.finish(status: status); + return result; + } catch (e, stackTrace) { + span.throwable = e; + await span.finish(status: const SpanStatus.internalError()); + await captureHandledException( + e, + stackTrace, + operation: operation, + ); + rethrow; + } + } + + Object? startSpan( + String operation, + String description, { + Map? data, + }) { + if (!isActive) return null; + + final span = Sentry.startTransaction( + _sanitizeFreeformText(description), + LogService.sanitize(operation), + bindToScope: false, + ); + + for (final entry in sanitizeData(data).entries) { + span.setData(entry.key, entry.value); + } + + return span; + } + + Future finishSpan( + Object? span, { + required bool success, + Object? throwable, + }) async { + if (span is! ISentrySpan) return; + + if (throwable != null) { + span.throwable = throwable; + } + + try { + await span.finish( + status: + success ? const SpanStatus.ok() : const SpanStatus.internalError(), + ); + } catch (e) { + _logTelemetryFailure('Span finish', e); + } + } + + Future captureHandledException( + Object exception, + StackTrace? stackTrace, { + required String operation, + }) async { + if (!isActive) return; + + try { + await Sentry.captureException( + exception, + stackTrace: stackTrace, + withScope: (scope) async { + await scope.setTag('operation', LogService.sanitize(operation)); + }, + ); + } catch (e) { + _logTelemetryFailure('Handled exception capture', e); + } + } + + static SentryEvent? filterEvent(SentryEvent event, Hint hint) { + final eventMessage = event.message; + final message = eventMessage?.copyWith( + formatted: _sanitizeFreeformText(eventMessage.formatted), + template: _sanitizeOptionalString(eventMessage.template), + params: eventMessage.params?.map(sanitizeValue).toList(), + ); + final exceptions = event.exceptions + ?.map((exception) => exception.copyWith( + value: exception.value == null + ? null + : _sanitizeFreeformText(exception.value!), + )) + .toList(); + final breadcrumbs = event.breadcrumbs + ?.map((crumb) => filterBreadcrumb(crumb, Hint())) + .toList() + ?..removeWhere((crumb) => crumb == null); + + return event.copyWith( + message: message, + exceptions: exceptions, + breadcrumbs: breadcrumbs?.cast(), + user: _scrubUser(event.user), + request: event.request == null ? null : _scrubRequest(event.request!), + // ignore: deprecated_member_use + extra: _sanitizeEventExtra(event), + tags: event.tags == null ? null : _sanitizeTags(event.tags!), + ); + } + + static SentryTransaction? filterTransaction(SentryTransaction transaction) { + final breadcrumbs = transaction.breadcrumbs + ?.map((crumb) => filterBreadcrumb(crumb, Hint())) + .toList() + ?..removeWhere((crumb) => crumb == null); + + return transaction.copyWith( + transaction: transaction.transaction == null + ? null + : scrubRouteName(transaction.transaction!), + breadcrumbs: breadcrumbs?.cast(), + request: transaction.request == null + ? null + : _scrubRequest(transaction.request!), + // ignore: deprecated_member_use + extra: _sanitizeTransactionExtra(transaction), + tags: transaction.tags == null ? null : _sanitizeTags(transaction.tags!), + ); + } + + static Breadcrumb? filterBreadcrumb(Breadcrumb? breadcrumb, Hint hint) { + if (breadcrumb == null) return null; + if (breadcrumb.type == 'http' || breadcrumb.category == 'http') return null; + + return breadcrumb.copyWith( + category: breadcrumb.category == null + ? null + : LogService.sanitize(breadcrumb.category!), + message: breadcrumb.message == null + ? null + : _sanitizeFreeformText(breadcrumb.message!), + data: sanitizeData(breadcrumb.data), + ); + } + + static Map sanitizeData(Map? data) { + if (data == null || data.isEmpty) return const {}; + + final sanitized = {}; + for (final entry in data.entries) { + final key = LogService.sanitize(entry.key); + if (_isSensitiveKey(key)) continue; + + final value = sanitizeValue(entry.value); + if (value != null) { + sanitized[key] = value; + } + } + return sanitized; + } + + static Object? sanitizeValue(Object? value) { + if (value == null || value is bool || value is num) return value; + + if (value is String) { + final sanitized = LogService.sanitize(value); + return sanitized.length > 120 ? sanitized.substring(0, 120) : sanitized; + } + + if (value is Map) { + final sanitized = {}; + for (final entry in value.entries.take(20)) { + final key = LogService.sanitize(entry.key.toString()); + if (_isSensitiveKey(key)) continue; + + final sanitizedValue = sanitizeValue(entry.value); + if (sanitizedValue != null) { + sanitized[key] = sanitizedValue; + } + } + return sanitized; + } + + if (value is Iterable) { + return value + .take(20) + .map(sanitizeValue) + .where((item) => item != null) + .toList(); + } + + return LogService.sanitize(value.runtimeType.toString()); + } + + static String? sanitizeUserId(String? userId) { + final trimmed = userId?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + if (_looksSensitiveUserId(trimmed)) return null; + + return trimmed.length > 80 ? trimmed.substring(0, 80) : trimmed; + } + + static String? _sanitizeOptionalString(String? value) { + return value == null ? null : _sanitizeFreeformText(value); + } + + static bool _isSensitiveKey(String key) { + final normalized = _normalizeDataKey(key); + const sensitiveKeys = { + 'authorization', + 'token', + 'access_token', + 'refresh_token', + 'auth_token', + 'password', + 'secret', + 'api_key', + 'apikey', + 'x_api_key', + 'header', + 'headers', + 'auth_header', + 'custom_proxy_header', + 'custom_proxy_headers', + 'url', + 'uri', + 'host', + 'backend_url', + 'base_url', + 'email', + 'amount', + 'account_id', + 'server_id', + 'transaction_id', + 'merchant_id', + 'category_id', + 'tag_id', + 'tag_ids', + 'user_id', + 'local_id', + 'account_name', + 'merchant_name', + 'category_name', + 'display_name', + 'transaction_name', + 'first_name', + 'last_name', + 'payload', + 'body', + 'chat', + 'message', + 'note', + 'sqlite', + 'database', + 'path', + }; + + return sensitiveKeys.contains(normalized); + } + + static Map _sanitizeTags(Map tags) { + final sanitized = {}; + for (final entry in tags.entries) { + final key = LogService.sanitize(entry.key); + if (_isSensitiveKey(key)) continue; + + sanitized[key] = LogService.sanitize(entry.value); + } + return sanitized; + } + + static SentryRequest _scrubRequest(SentryRequest request) { + return SentryRequest( + method: request.method, + url: _scrubUrlToPath(request.url), + ); + } + + static SentryUser? _scrubUser(SentryUser? user) { + if (user == null) return null; + + final safeUserId = sanitizeUserId(user.id); + return SentryUser(id: safeUserId ?? 'redacted'); + } + + static Map? _sanitizeEventExtra(SentryEvent event) { + // ignore: deprecated_member_use + final extra = event.extra; + return extra == null ? null : sanitizeData(extra); + } + + static Map? _sanitizeTransactionExtra( + SentryTransaction transaction, + ) { + // ignore: deprecated_member_use + final extra = transaction.extra; + return extra == null ? null : sanitizeData(extra); + } + + static bool _looksSensitiveUserId(String userId) { + return RegExp( + r'(@|https?://|bearer\s+|authorization|token|secret|password|api[-_]?key)', + caseSensitive: false, + ).hasMatch(userId); + } + + static String _sanitizeFreeformText(String value) { + final sanitized = LogService.sanitize(value); + if (_looksLikeDatabaseDetail(sanitized)) { + return 'Local database operation failed'; + } + + return sanitized.length > 240 ? sanitized.substring(0, 240) : sanitized; + } + + static bool _looksLikeDatabaseDetail(String value) { + return RegExp( + r'\b(sqflite|sqlite|databaseexception|sql\s|select\s|insert\s|update\s|delete\s|pragma\s|from\s+\w+|where\s+\w+|no such table)\b', + caseSensitive: false, + ).hasMatch(value); + } + + static void _logTelemetryFailure(String action, Object error) { + LogService.instance.warning( + 'Telemetry', + '$action failed; continuing without interrupting app flow: ' + '${error.runtimeType}', + ); + } + + static RouteSettings? scrubRouteSettings(RouteSettings? settings) { + if (settings == null) return null; + + return RouteSettings( + name: settings.name == null ? null : scrubRouteName(settings.name!), + ); + } + + static String scrubRouteName(String value) { + final sanitized = _sanitizeFreeformText(value); + final parsed = Uri.tryParse(sanitized); + final rawPath = parsed?.hasAbsolutePath == true ? parsed!.path : sanitized; + final path = rawPath.split('?').first; + + final scrubbed = path.split('/').map((segment) { + if (segment.isEmpty) return segment; + if (_looksLikeRouteIdentifier(segment)) return ':id'; + + return segment; + }).join('/'); + + return scrubbed.length > 240 ? scrubbed.substring(0, 240) : scrubbed; + } + + static String? _scrubUrlToPath(String? value) { + if (value == null || value.trim().isEmpty) return null; + + final parsed = Uri.tryParse(value); + final path = parsed == null || parsed.path.isEmpty ? value : parsed.path; + final scrubbed = scrubRouteName(path); + + return scrubbed.isEmpty ? null : scrubbed; + } + + static String _normalizeDataKey(String key) { + return key + .replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (match) => '${match.group(1)}_${match.group(2)}', + ) + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + } + + static bool _looksLikeRouteIdentifier(String segment) { + return RegExp(r'^\d+$').hasMatch(segment) || + RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ).hasMatch(segment) || + RegExp(r'^[a-z]+_[a-z0-9_-]{8,}$', caseSensitive: false) + .hasMatch(segment) || + RegExp(r'^[0-9a-f]{16,}$', caseSensitive: false).hasMatch(segment); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a9d66b802..cec2032af 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -198,14 +198,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 - url: "https://pub.dev" - source: hosted - version: "2.0.33" flutter_markdown: dependency: "direct main" description: @@ -214,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7+1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + url: "https://pub.dev" + source: hosted + version: "2.0.31" flutter_secure_storage: dependency: "direct main" description: @@ -332,26 +332,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -372,18 +372,18 @@ packages: dependency: transitive description: name: local_auth_android - sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3" url: "https://pub.dev" source: hosted - version: "1.0.56" + version: "1.0.52" local_auth_darwin: dependency: transitive description: name: local_auth_darwin - sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055" url: "https://pub.dev" source: hosted - version: "1.6.1" + version: "1.6.0" local_auth_platform_interface: dependency: transitive description: @@ -404,34 +404,34 @@ packages: dependency: transitive description: name: markdown - sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" url: "https://pub.dev" source: hosted - version: "7.3.1" + version: "7.3.0" matcher: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" nested: dependency: transitive description: @@ -568,6 +568,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + sentry: + dependency: transitive + description: + name: sentry + sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb" + url: "https://pub.dev" + source: hosted + version: "8.14.2" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8" + url: "https://pub.dev" + source: hosted + version: "8.14.2" shared_preferences: dependency: "direct main" description: @@ -649,10 +665,10 @@ packages: dependency: transitive description: name: sqflite_android - sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" url: "https://pub.dev" source: hosted - version: "2.4.2+2" + version: "2.4.1" sqflite_common: dependency: transitive description: @@ -721,10 +737,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.4" typed_data: dependency: transitive description: @@ -745,18 +761,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.20" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: b1aca26728b7cc7a3af971bb6f601554a8ae9df2e0a006de8450ba06a17ad36a + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.3.4" url_launcher_linux: dependency: transitive description: @@ -769,10 +785,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: @@ -785,10 +801,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -825,18 +841,18 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc url: "https://pub.dev" source: hosted - version: "1.1.20" + version: "1.1.19" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" vm_service: dependency: transitive description: @@ -886,5 +902,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index efcb72b9c..1b454b539 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: package_info_plus: ^8.0.0 local_auth: ^2.3.0 flutter_markdown: ^0.7.2 + sentry_flutter: ^8.14.2 dev_dependencies: flutter_test: diff --git a/mobile/test/services/auth_service_test.dart b/mobile/test/services/auth_service_test.dart index f23e9ef79..6f3eec1aa 100644 --- a/mobile/test/services/auth_service_test.dart +++ b/mobile/test/services/auth_service_test.dart @@ -1,51 +1,104 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sure_mobile/services/api_config.dart'; import 'package:sure_mobile/services/auth_service.dart'; void main() { - group('AuthService', () { - late HttpServer server; + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); - setUp(() async { - server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - ApiConfig.clearApiKeyAuth(); - ApiConfig.setCustomProxyHeaders([]); - ApiConfig.setBaseUrl('http://${server.address.host}:${server.port}'); + tearDown(() { + ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl); + }); + + test('login handles string errors payloads without throwing', () async { + final result = await _loginWithResponse({ + 'errors': 'Invalid login payload', }); - tearDown(() async { - ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl); - await server.close(force: true); + expect(result['success'], false); + expect(result['error'], 'Invalid login payload'); + }); + + test('login flattens mapped error responses', () async { + final result = await _loginWithResponse({ + 'errors': { + 'email': ['is invalid'], + 'base': 'try again', + }, }); - test('login handles string errors payloads without throwing', () async { - final subscription = server.listen((request) { - if (request.method != 'POST' || - request.uri.path != '/api/v1/auth/login') { - request.response.statusCode = 404; - request.response.close(); - return; - } + expect(result['success'], false); + expect(result['error'], 'is invalid, try again'); + }); - request.response - ..statusCode = 422 - ..headers.contentType = ContentType.json - ..write(jsonEncode({'errors': 'Invalid login payload'})) - ..close(); - }); - addTearDown(subscription.cancel); + test('login does not persist tokens when user parsing fails', () async { + final authService = AuthService(); - final result = await AuthService().login( - email: 'user@example.test', - password: 'password', - deviceInfo: const {'platform': 'test'}, - ); + final result = await _loginWithResponse( + { + 'access_token': 'access-token', + 'refresh_token': 'refresh-token', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'created_at': 0, + 'user': { + 'id': 'user_1', + }, + }, + statusCode: 200, + authService: authService, + ); - expect(result['success'], false); - expect(result['error'], 'Invalid login payload'); - }); + expect(result['success'], false); + expect(result['error'], 'Invalid response from server'); + expect(await authService.getStoredTokens(), isNull); + expect(await authService.getStoredUser(), isNull); }); } + +Future> _loginWithResponse( + Map responseBody, { + int statusCode = 422, + AuthService? authService, +}) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final subscription = server.listen((request) async { + if (request.method != 'POST' || request.uri.path != '/api/v1/auth/login') { + request.response + ..statusCode = 404 + ..headers.contentType = ContentType.json + ..write(jsonEncode({'error': 'Unexpected route'})); + await request.response.close(); + return; + } + + request.response + ..statusCode = statusCode + ..headers.contentType = ContentType.json + ..write(jsonEncode(responseBody)); + await request.response.close(); + }); + + try { + ApiConfig.setBaseUrl('http://${server.address.host}:${server.port}'); + return await (authService ?? AuthService()).login( + email: 'user@example.test', + password: 'password', + deviceInfo: const { + 'device_id': 'test-device', + 'device_name': 'Test Device', + 'device_type': 'test', + 'os_version': 'test', + 'app_version': 'test', + }, + ); + } finally { + await subscription.cancel(); + await server.close(force: true); + } +} diff --git a/mobile/test/services/log_service_test.dart b/mobile/test/services/log_service_test.dart index 303298a44..46a8536f6 100644 --- a/mobile/test/services/log_service_test.dart +++ b/mobile/test/services/log_service_test.dart @@ -74,6 +74,13 @@ void main() { expect(LogService.sanitize(message), message); }); + test('sanitize preserves safe name-adjacent operational fields', () { + const message = + 'filename=main.dart pageName=transactions stage=boot successMessage=ok'; + + expect(LogService.sanitize(message), message); + }); + test('sanitize redacts repeated sensitive values', () { final sanitized = LogService.sanitize( 'email=one@example.com email=two@example.com ' diff --git a/mobile/test/services/telemetry_service_test.dart b/mobile/test/services/telemetry_service_test.dart new file mode 100644 index 000000000..bf520bad3 --- /dev/null +++ b/mobile/test/services/telemetry_service_test.dart @@ -0,0 +1,281 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sure_mobile/services/telemetry_service.dart'; + +void main() { + group('TelemetryConfig', () { + test('defaults invalid sample rates to safe Rails-parity values', () { + expect( + TelemetryConfig.sampleRate('not-a-number', defaultValue: 0.25), + 0.25, + ); + expect(TelemetryConfig.sampleRate('2', defaultValue: 0.25), 1); + expect(TelemetryConfig.sampleRate('-1', defaultValue: 0.25), 0); + }); + }); + + group('TelemetryService', () { + test('runs app normally when no Sentry DSN is configured', () async { + final service = TelemetryService( + config: const TelemetryConfig( + dsn: '', + environment: 'test', + release: '', + tracesSampleRate: 0.25, + profilesSampleRate: 0.25, + ), + ); + var appStarted = false; + + await service.initialize(appRunner: () { + appStarted = true; + }); + + expect(appStarted, isTrue); + expect(service.isActive, isFalse); + expect(service.navigatorObservers, isEmpty); + }); + + test('sanitizes telemetry data before breadcrumbs and event extras', () { + final sanitized = TelemetryService.sanitizeData({ + 'page': 2, + 'success': true, + 'transaction_id': 'txn_123', + 'accountId': 'acct_123', + 'amount': '123.45', + 'backend_url': 'https://sure.example.test', + 'message': 'raw response body', + 'page_count': 3, + 'success_message': 'finished', + 'stage': 'sync', + 'hostname': 'localhost', + 'storage_path': 'cache/logs', + 'status': 'completed', + }); + + expect(sanitized, containsPair('page', 2)); + expect(sanitized, containsPair('success', true)); + expect(sanitized, containsPair('page_count', 3)); + expect(sanitized, containsPair('success_message', 'finished')); + expect(sanitized, containsPair('stage', 'sync')); + expect(sanitized, containsPair('hostname', 'localhost')); + expect(sanitized, containsPair('storage_path', 'cache/logs')); + expect(sanitized, containsPair('status', 'completed')); + expect(sanitized, isNot(contains('transaction_id'))); + expect(sanitized, isNot(contains('accountId'))); + expect(sanitized, isNot(contains('amount'))); + expect(sanitized, isNot(contains('backend_url'))); + expect(sanitized, isNot(contains('message'))); + }); + + test('sanitizes nested telemetry maps without preserving sensitive keys', + () { + final sanitized = TelemetryService.sanitizeValue({ + 'status': 'ok', + 'pagination': { + 'page': 1, + 'transaction_id': 'txn_123', + 'backend_url': 'https://sure.example.test', + }, + 'items': [ + {'success': true, 'account_id': 'acct_123'}, + ], + }); + + expect( + sanitized, + equals({ + 'status': 'ok', + 'pagination': {'page': 1}, + 'items': [ + {'success': true}, + ], + }), + ); + }); + + test('caps iterable telemetry values after sanitizing entries', () { + final sanitized = TelemetryService.sanitizeValue( + List.generate( + 25, (index) => {'page': index, 'account_id': 'acct_$index'}), + ); + + expect(sanitized, isA>()); + expect(sanitized as List, hasLength(20)); + expect(sanitized.first, equals({'page': 0})); + expect(sanitized.last, equals({'page': 19})); + }); + + test('traceAsync rethrows callback errors when telemetry is inactive', + () async { + final service = TelemetryService( + config: const TelemetryConfig( + dsn: '', + environment: 'test', + release: '', + tracesSampleRate: 0.25, + profilesSampleRate: 0.25, + ), + ); + + expect( + service.traceAsync( + 'sync.transactions_fetch', + 'Mobile transaction fetch', + () => throw StateError('offline failure'), + ), + throwsA(isA()), + ); + }); + + test('preserves only safe opaque Sentry user ids', () { + expect( + TelemetryService.sanitizeUserId( + '123e4567-e89b-12d3-a456-426614174000', + ), + '123e4567-e89b-12d3-a456-426614174000', + ); + expect(TelemetryService.sanitizeUserId('user@example.com'), isNull); + expect( + TelemetryService.sanitizeUserId('https://sure.example.test/user/1'), + isNull, + ); + expect(TelemetryService.sanitizeUserId('Bearer token'), isNull); + }); + + test('filterEvent removes sensitive user fields', () { + final event = SentryEvent( + user: SentryUser( + id: 'user@example.com', + email: 'user@example.com', + username: 'full name', + ), + ); + + final filtered = TelemetryService.filterEvent(event, Hint())!; + final userJson = filtered.user!.toJson().toString(); + + expect(filtered.user, isNotNull); + expect(filtered.user!.id, 'redacted'); + expect(userJson, isNot(contains('user@example.com'))); + expect(userJson, isNot(contains('full name'))); + }); + + test('drops HTTP breadcrumbs instead of preserving URLs or headers', () { + final crumb = Breadcrumb.http( + url: Uri.parse('https://sure.example.test/api/transactions'), + method: 'GET', + ); + + expect(TelemetryService.filterBreadcrumb(crumb, Hint()), isNull); + }); + + test('filters breadcrumbs before they leave the device', () { + final crumb = Breadcrumb( + category: 'sync', + message: 'Fetched email=user@example.com amount=123.45', + data: { + 'page_count': 2, + 'success_message': 'completed', + 'account_id': 'acct_123', + 'merchantName': 'Corner Store', + }, + ); + + final filtered = TelemetryService.filterBreadcrumb(crumb, Hint())!; + + expect(filtered.message, isNot(contains('user@example.com'))); + expect(filtered.message, isNot(contains('123.45'))); + expect(filtered.data, containsPair('page_count', 2)); + expect(filtered.data, containsPair('success_message', 'completed')); + expect(filtered.data, isNot(contains('account_id'))); + expect(filtered.data, isNot(contains('merchantName'))); + }); + + test('scrubs navigator route ids and drops route arguments', () { + final settings = TelemetryService.scrubRouteSettings( + const RouteSettings( + name: '/accounts/123/transactions', + arguments: {'account_id': 'acct_123'}, + ), + )!; + + expect(settings.name, '/accounts/:id/transactions'); + expect(settings.arguments, isNull); + expect( + TelemetryService.scrubRouteName('/accounts/acct_12345678/transactions'), + '/accounts/:id/transactions', + ); + expect( + TelemetryService.scrubRouteName('/api/v1/auth/login'), + '/api/v1/auth/login', + ); + }); + + test('sanitizes event messages, exceptions, request data, and tags', () { + final event = SentryEvent( + message: const SentryMessage( + 'Failed for email=user@example.com amount=123.45', + ), + exceptions: const [ + SentryException( + type: 'StateError', + value: 'backendUrl=https://sure.example.test', + ), + ], + request: SentryRequest( + url: 'https://sure.example.test/api/transactions', + method: 'POST', + data: {'name': 'Coffee'}, + headers: {'Authorization': 'Bearer secret-token'}, + ), + tags: { + 'operation': 'sync.transactions_fetch', + 'account_id': 'acct_123', + }, + // ignore: deprecated_member_use + extra: { + 'page': 1, + 'merchantName': 'Corner Store', + }, + ); + + final filtered = TelemetryService.filterEvent(event, Hint())!; + + expect(filtered.message!.formatted, isNot(contains('user@example.com'))); + expect(filtered.message!.formatted, isNot(contains('123.45'))); + expect( + filtered.exceptions!.single.value, isNot(contains('sure.example'))); + expect(filtered.request!.url, '/api/transactions'); + expect(filtered.request!.data, isNull); + expect(filtered.request!.headers, isEmpty); + expect( + filtered.tags, containsPair('operation', 'sync.transactions_fetch')); + expect(filtered.tags, isNot(contains('account_id'))); + // ignore: deprecated_member_use + expect(filtered.extra, containsPair('page', 1)); + // ignore: deprecated_member_use + expect(filtered.extra, isNot(contains('merchantName'))); + }); + + test('collapses local database exception details before sending', () { + final event = SentryEvent( + exceptions: const [ + SentryException( + type: 'DatabaseException', + value: 'DatabaseException(no such table: transactions) ' + 'sql SELECT * FROM transactions WHERE account_id = acct_123', + ), + ], + ); + + final filtered = TelemetryService.filterEvent(event, Hint())!; + + expect( + filtered.exceptions!.single.value, + 'Local database operation failed', + ); + }); + }); +}