add sentry

This commit is contained in:
Phuoc Nguyen
2025-12-11 13:44:26 +07:00
parent f130820131
commit e3632d4445
9 changed files with 445 additions and 126 deletions

View File

@@ -56,21 +56,10 @@ class AuthInterceptor extends Interceptor {
// Get session data from secure storage
final sid = await _secureStorage.read(key: 'frappe_sid');
final csrfToken = await _secureStorage.read(key: 'frappe_csrf_token');
final fullName = await _secureStorage.read(key: 'frappe_full_name');
final userId = await _secureStorage.read(key: 'frappe_user_id');
if (sid != null && csrfToken != null) {
// Build cookie header with all required fields
final cookieHeader = [
'sid=$sid',
'full_name=${fullName ?? "User"}',
'system_user=no',
'user_id=${userId != null ? Uri.encodeComponent(userId) : ApiConstants.frappePublicUserId}',
'user_image=',
].join('; ');
// Add Frappe session headers
options.headers['Cookie'] = cookieHeader;
// Only send sid in Cookie header - other fields are not needed
options.headers['Cookie'] = 'sid=$sid';
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
}

View File

@@ -447,6 +447,7 @@ Future<CacheOptions> cacheOptions(Ref ref) async {
Future<Dio> dio(Ref ref) async {
final dio = Dio();
// Base configuration
dio
..options = BaseOptions(
@@ -466,7 +467,7 @@ Future<Dio> dio(Ref ref) async {
},
)
// Add interceptors in order
// 1. Custom Curl interceptor (first to log cURL commands)
// 1. Custom Curl interceptor (logs cURL commands)
// Uses debugPrint and developer.log for better visibility
..interceptors.add(CustomCurlLoggerInterceptor())
// 2. Logging interceptor

View File

@@ -89,13 +89,8 @@ class FrappeAuthService {
const url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
// Build cookie header
// Get stored session - only need sid and csrf_token
final storedSession = await getStoredSession();
final cookieHeader = _buildCookieHeader(
sid: storedSession!['sid']!,
fullName: storedSession['fullName']!,
userId: storedSession['userId']!,
);
final response = await _dio.post<Map<String, dynamic>>(
url,
@@ -109,7 +104,7 @@ class FrappeAuthService {
},
options: Options(
headers: {
'Cookie': cookieHeader,
'Cookie': 'sid=${storedSession!['sid']!}',
'X-Frappe-Csrf-Token': storedSession['csrfToken']!,
'Content-Type': 'application/json',
},
@@ -203,31 +198,13 @@ class FrappeAuthService {
return sid != null && csrfToken != null;
}
/// Build cookie header string
String _buildCookieHeader({
required String sid,
required String fullName,
required String userId,
}) {
return [
'sid=$sid',
'full_name=$fullName',
'system_user=no',
'user_id=${Uri.encodeComponent(userId)}',
'user_image=',
].join('; ');
}
/// Get headers for Frappe API requests
/// Only sends sid in Cookie - other fields are not needed
Future<Map<String, String>> getHeaders() async {
final session = await ensureSession();
return {
'Cookie': _buildCookieHeader(
sid: session['sid']!,
fullName: session['fullName']!,
userId: session['userId']!,
),
'Cookie': 'sid=${session['sid']!}',
'X-Frappe-Csrf-Token': session['csrfToken']!,
'Content-Type': 'application/json',
};

View File

@@ -4,12 +4,16 @@ import 'package:onesignal_flutter/onesignal_flutter.dart';
/// OneSignal service for managing push notifications and external user ID.
///
/// This service handles:
/// - Initializing OneSignal SDK
/// - Setting external user ID after login (using phone number)
/// - Restoring external user ID on app startup
/// - Clearing external user ID on logout
///
/// Usage:
/// ```dart
/// // Initialize in main.dart
/// await OneSignalService.init(appId: 'your-app-id');
///
/// // After successful login
/// await OneSignalService.login(phoneNumber);
///
@@ -19,6 +23,78 @@ import 'package:onesignal_flutter/onesignal_flutter.dart';
class OneSignalService {
OneSignalService._();
/// OneSignal App ID - Replace with your actual App ID from OneSignal dashboard
static const String _defaultAppId = '778ca22d-c719-4ec8-86cb-a6b911166066';
/// Initialize OneSignal SDK
///
/// Must be called before using any other OneSignal methods.
/// Sets up push subscription observers and requests notification permission.
///
/// [appId] - Optional App ID override (uses default if not provided)
/// [requestPermission] - Whether to request notification permission (default: true)
static Future<void> init({
String? appId,
bool requestPermission = true,
}) async {
try {
// Set debug log level (verbose in debug, none in release)
OneSignal.Debug.setLogLevel(kDebugMode ? OSLogLevel.verbose : OSLogLevel.none);
// Initialize with App ID
OneSignal.initialize(appId ?? _defaultAppId);
debugPrint('🔔 OneSignal initialized');
// Add push subscription observer to track subscription state changes
OneSignal.User.pushSubscription.addObserver((state) {
debugPrint('🔔 Push subscription state changed:');
debugPrint(' Previous - optedIn: ${state.previous.optedIn}, id: ${state.previous.id}');
debugPrint(' Current - optedIn: ${state.current.optedIn}, id: ${state.current.id}');
debugPrint(' Subscription ID: ${state.current.id}');
debugPrint(' Push Token: ${state.current.token}');
if (state.current.id != null) {
debugPrint('🔔 ✅ Device successfully subscribed!');
}
});
// Add notification permission observer
OneSignal.Notifications.addPermissionObserver((isGranted) {
debugPrint('🔔 Notification permission changed: $isGranted');
});
// Request permission if enabled
if (requestPermission) {
final accepted = await OneSignal.Notifications.requestPermission(true);
debugPrint('🔔 Permission request result: $accepted');
}
// Give OneSignal SDK time to complete initialization and server registration
await Future<void>.delayed(const Duration(seconds: 2));
// Log current subscription status
_logSubscriptionStatus();
} catch (e) {
debugPrint('🔔 OneSignal error: Failed to initialize - $e');
}
}
/// Log current subscription status for debugging
static void _logSubscriptionStatus() {
final optedIn = OneSignal.User.pushSubscription.optedIn;
final id = OneSignal.User.pushSubscription.id;
final token = OneSignal.User.pushSubscription.token;
debugPrint('🔔 Current subscription status:');
debugPrint(' Opted In: $optedIn');
debugPrint(' Subscription ID: $id');
debugPrint(' Push Token: $token');
if (id == null) {
debugPrint('🔔 ⚠️ Subscription ID is null - check device connectivity and OneSignal app ID');
}
}
/// Login user to OneSignal by setting external user ID.
///
/// This associates the device with the user's phone number,

View File

@@ -0,0 +1,250 @@
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// Sentry service for error tracking and performance monitoring.
///
/// This service handles:
/// - Initializing Sentry SDK
/// - Capturing exceptions and errors
/// - Capturing custom messages
/// - Setting user context after login
/// - Performance monitoring
///
/// Usage:
/// ```dart
/// // Initialize in main.dart
/// await SentryService.init(
/// dsn: 'your-sentry-dsn',
/// appRunner: () => runApp(MyApp()),
/// );
///
/// // Capture exception
/// SentryService.captureException(error, stackTrace: stackTrace);
///
/// // Capture message
/// SentryService.captureMessage('User performed action X');
///
/// // Set user context after login
/// SentryService.setUser(userId: '123', email: 'user@example.com');
/// ```
class SentryService {
SentryService._();
/// Sentry DSN - Replace with your actual DSN from Sentry dashboard
static const String _dsn = 'https://2c5893508a29e5ea750b64d5ee31aeef@o4509632266436608.ingest.us.sentry.io/4510464530972672';
/// Initialize Sentry SDK
///
/// Must be called before runApp() in main.dart.
/// Wraps the app with Sentry error boundary.
///
/// [dsn] - Optional DSN override (uses default if not provided)
/// [appRunner] - The function that runs the app (typically runApp(MyApp()))
/// [environment] - Environment name (e.g., 'development', 'production')
static Future<void> init({
String? dsn,
required Future<void> Function() appRunner,
String? environment,
}) async {
// Get package info for release version
final packageInfo = await PackageInfo.fromPlatform();
final release = 'partner@${packageInfo.version}+${packageInfo.buildNumber}';
await SentryFlutter.init(
(options) {
options
..dsn = dsn ?? _dsn
// Release version: worker@1.0.1+29
..release = release
// Environment configuration
..environment = environment ?? (kReleaseMode ? 'production' : 'development')
// Performance monitoring
..tracesSampleRate = kReleaseMode ? 0.2 : 1.0 // 20% in prod, 100% in dev
..profilesSampleRate = kReleaseMode ? 0.2 : 1.0
// Enable automatic instrumentation
..enableAutoPerformanceTracing = true
// Capture failed requests
..captureFailedRequests = true
// Debug mode settings
..debug = kDebugMode
// Add app-specific tags
..beforeSend = (event, hint) {
// Filter out certain errors if needed
// Return null to drop the event
return event;
};
},
appRunner: appRunner,
);
debugPrint('🔴 Sentry initialized (release: $release, enabled: ${!kDebugMode})');
}
/// Capture an exception with optional stack trace
///
/// [exception] - The exception to capture
/// [stackTrace] - Optional stack trace
/// [hint] - Optional hint with additional context
static Future<void> captureException(
dynamic exception, {
StackTrace? stackTrace,
Hint? hint,
}) async {
try {
await Sentry.captureException(
exception,
stackTrace: stackTrace,
hint: hint,
);
debugPrint('🔴 Sentry: Exception captured - ${exception.runtimeType}');
} catch (e) {
debugPrint('🔴 Sentry error: Failed to capture exception - $e');
}
}
/// Capture a custom message
///
/// [message] - The message to capture
/// [level] - Severity level (default: info)
/// [params] - Optional parameters to include
static Future<void> captureMessage(
String message, {
SentryLevel level = SentryLevel.info,
Map<String, dynamic>? params,
}) async {
try {
await Sentry.captureMessage(
message,
level: level,
withScope: params != null
? (scope) {
params.forEach((key, value) {
scope.setExtra(key, value);
});
}
: null,
);
debugPrint('🔴 Sentry: Message captured - $message');
} catch (e) {
debugPrint('🔴 Sentry error: Failed to capture message - $e');
}
}
/// Set user context for error tracking
///
/// Call this after successful login to associate errors with users.
///
/// [userId] - User's unique identifier
/// [email] - User's email (optional)
/// [username] - User's display name (optional)
/// [extras] - Additional user data (optional)
static Future<void> setUser({
required String userId,
String? email,
String? username,
Map<String, dynamic>? extras,
}) async {
try {
await Sentry.configureScope((scope) {
scope.setUser(SentryUser(
id: userId,
email: email,
username: username,
data: extras,
));
});
debugPrint('🔴 Sentry: User set - $userId');
} catch (e) {
debugPrint('🔴 Sentry error: Failed to set user - $e');
}
}
/// Clear user context on logout
static Future<void> clearUser() async {
try {
await Sentry.configureScope((scope) {
scope.setUser(null);
});
debugPrint('🔴 Sentry: User cleared');
} catch (e) {
debugPrint('🔴 Sentry error: Failed to clear user - $e');
}
}
/// Add a breadcrumb for tracking user actions
///
/// Breadcrumbs are used to track the sequence of events leading to an error.
///
/// [message] - Description of the action
/// [category] - Category of the breadcrumb (e.g., 'navigation', 'ui.click')
/// [data] - Additional data (optional)
static Future<void> addBreadcrumb({
required String message,
String? category,
Map<String, dynamic>? data,
SentryLevel level = SentryLevel.info,
}) async {
try {
await Sentry.addBreadcrumb(Breadcrumb(
message: message,
category: category,
data: data,
level: level,
timestamp: DateTime.now(),
));
} catch (e) {
debugPrint('🔴 Sentry error: Failed to add breadcrumb - $e');
}
}
/// Set a tag for filtering in Sentry dashboard
///
/// [key] - Tag name
/// [value] - Tag value
static Future<void> setTag(String key, String value) async {
try {
await Sentry.configureScope((scope) {
scope.setTag(key, value);
});
} catch (e) {
debugPrint('🔴 Sentry error: Failed to set tag - $e');
}
}
/// Set extra context data
///
/// [key] - Context key
/// [value] - Context value (will be serialized)
static Future<void> setExtra(String key, dynamic value) async {
try {
await Sentry.configureScope((scope) {
scope.setExtra(key, value);
});
} catch (e) {
debugPrint('🔴 Sentry error: Failed to set extra - $e');
}
}
/// Start a performance transaction
///
/// [name] - Transaction name
/// [operation] - Operation type (e.g., 'http.client', 'ui.load')
///
/// Returns the transaction to be finished later.
static ISentrySpan? startTransaction(String name, String operation) {
try {
return Sentry.startTransaction(name, operation);
} catch (e) {
debugPrint('🔴 Sentry error: Failed to start transaction - $e');
return null;
}
}
}

View File

@@ -1,17 +1,17 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:onesignal_flutter/onesignal_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:worker/app.dart';
import 'package:worker/core/database/app_settings_box.dart';
import 'package:worker/core/database/hive_initializer.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'package:worker/core/services/onesignal_service.dart';
import 'package:worker/core/services/sentry_service.dart';
import 'package:worker/firebase_options.dart';
/// Main entry point of the Worker Mobile App
///
@@ -45,72 +45,35 @@ void main() async {
/// Initialize all app dependencies with comprehensive error handling
Future<void> _initializeApp() async {
// Set up error handlers before anything else
_setupErrorHandlers();
// Initialize Sentry first to capture any initialization errors
// Sentry wraps the app runner to capture errors during startup
await SentryService.init(
appRunner: () async {
// Set up error handlers
_setupErrorHandlers();
try {
// Initialize core dependencies in parallel for faster startup
await Future.wait([_initializeHive(), _initializeSharedPreferences()]);
try {
// Initialize core dependencies in parallel for faster startup
await Future.wait([_initializeHive(), _initializeSharedPreferences()]);
// Initialize OneSignal with verbose logging for debugging
OneSignal.Debug.setLogLevel(OSLogLevel.verbose);
// Initialize OneSignal push notifications
await OneSignalService.init();
// Initialize with your OneSignal App ID
OneSignal.initialize("778ca22d-c719-4ec8-86cb-a6b911166066");
// Run the app with Riverpod ProviderScope
runApp(const ProviderScope(child: WorkerApp()));
} catch (error, stackTrace) {
// Critical initialization error - capture and show error screen
debugPrint('Failed to initialize app: $error');
debugPrint('StackTrace: $stackTrace');
debugPrint('🔔 OneSignal initialized');
// Report to Sentry
await SentryService.captureException(error, stackTrace: stackTrace);
// Add observer BEFORE requesting permission to catch the subscription event
OneSignal.User.pushSubscription.addObserver((state) {
debugPrint('🔔 Push subscription state changed:');
debugPrint(' Previous - optedIn: ${state.previous.optedIn}, id: ${state.previous.id}');
debugPrint(' Current - optedIn: ${state.current.optedIn}, id: ${state.current.id}');
debugPrint(' Subscription ID: ${state.current.id}');
debugPrint(' Push Token: ${state.current.token}');
// Save subscription info when available
if (state.current.id != null) {
debugPrint('🔔 ✅ Device successfully subscribed!');
// Run minimal error app
runApp(_buildErrorApp(error, stackTrace));
}
});
// Add notification permission observer
OneSignal.Notifications.addPermissionObserver((isGranted) {
debugPrint('🔔 Notification permission changed: $isGranted');
});
// Request permission - TRUE to show the native permission prompt
final accepted = await OneSignal.Notifications.requestPermission(true);
debugPrint('🔔 Permission request result: $accepted');
// Give OneSignal SDK time to complete initialization and server registration
// This is necessary because the subscription happens asynchronously
await Future.delayed(const Duration(seconds: 2));
// Check current subscription status after initialization completes
final optedIn = OneSignal.User.pushSubscription.optedIn;
final id = OneSignal.User.pushSubscription.id;
final token = OneSignal.User.pushSubscription.token;
debugPrint('🔔 Current subscription status (after delay):');
debugPrint(' Opted In: $optedIn');
debugPrint(' Subscription ID: $id');
debugPrint(' Push Token: $token');
if (id == null) {
debugPrint('🔔 ⚠️ Subscription ID is still null - check device connectivity and OneSignal app ID');
}
// Run the app with Riverpod ProviderScope
runApp(const ProviderScope(child: WorkerApp()));
} catch (error, stackTrace) {
// Critical initialization error - show error screen
debugPrint('Failed to initialize app: $error');
debugPrint('StackTrace: $stackTrace');
// Run minimal error app
runApp(_buildErrorApp(error, stackTrace));
}
},
);
}
/// Initialize Hive database
@@ -164,7 +127,8 @@ Future<void> _initializeSharedPreferences() async {
/// Set up global error handlers
///
/// Captures and logs all Flutter framework errors and uncaught exceptions
/// Captures and logs all Flutter framework errors and uncaught exceptions.
/// Reports errors to Sentry for crash analytics.
void _setupErrorHandlers() {
// Handle Flutter framework errors
FlutterError.onError = (FlutterErrorDetails details) {
@@ -176,8 +140,11 @@ void _setupErrorHandlers() {
debugPrint('StackTrace: ${details.stack}');
}
// In production, you would send to crash analytics service
// Example: FirebaseCrashlytics.instance.recordFlutterError(details);
// Report to Sentry
SentryService.captureException(
details.exception,
stackTrace: details.stack,
);
};
// Handle errors outside of Flutter framework
@@ -187,27 +154,11 @@ void _setupErrorHandlers() {
debugPrint('StackTrace: $stackTrace');
}
// In production, you would send to crash analytics service
// Example: FirebaseCrashlytics.instance.recordError(error, stackTrace);
// Report to Sentry
SentryService.captureException(error, stackTrace: stackTrace);
return true; // Return true to indicate error was handled
};
// Handle zone errors (async errors not caught by Flutter)
runZonedGuarded(
() {
// App will run in this zone
},
(error, stackTrace) {
if (kDebugMode) {
debugPrint('Zone Error: $error');
debugPrint('StackTrace: $stackTrace');
}
// In production, you would send to crash analytics service
// Example: FirebaseCrashlytics.instance.recordError(error, stackTrace);
},
);
}
/// Build minimal error app when initialization fails