diff --git a/lib/isolates.dart b/lib/isolates.dart index 4149bb8..0e1497b 100644 --- a/lib/isolates.dart +++ b/lib/isolates.dart @@ -14,6 +14,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:mewe_maps/repositories/storage/storage_repository.dart'; import 'package:mewe_maps/services/firebase/firebase_initialization.dart'; +import 'package:mewe_maps/utils/loggly_logger.dart'; Future initializeIsolate() async { WidgetsFlutterBinding.ensureInitialized(); @@ -25,6 +26,7 @@ Future initializeIsolate() async { exit(1); }; + initializeLogglyLogger(); await initializeFirebase(); await StorageRepository.initialize(); } diff --git a/lib/models/loggly_log_entry.dart b/lib/models/loggly_log_entry.dart new file mode 100644 index 0000000..dea0ac5 --- /dev/null +++ b/lib/models/loggly_log_entry.dart @@ -0,0 +1,42 @@ +// Copyright MeWe 2025. +// +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/. + +import 'package:json_annotation/json_annotation.dart'; + +part 'loggly_log_entry.g.dart'; + +@JsonSerializable() +class LogglyLogEntry { + @JsonKey(name: "timestamp") + final String timestamp; + @JsonKey(name: "level") + final String level; + @JsonKey(name: "message") + final String message; + @JsonKey(name: "userId") + final String? userId; + @JsonKey(name: "tags") + final List tags; + @JsonKey(name: "params") + final Map? params; + + const LogglyLogEntry({ + required this.timestamp, + required this.level, + required this.message, + this.userId, + required this.tags, + this.params, + }); + + factory LogglyLogEntry.fromJson(Map json) => _$LogglyLogEntryFromJson(json); + + Map toJson() => _$LogglyLogEntryToJson(this); +} diff --git a/lib/modules/map/bloc/map_bloc.dart b/lib/modules/map/bloc/map_bloc.dart index 9b2c799..9f5011c 100644 --- a/lib/modules/map/bloc/map_bloc.dart +++ b/lib/modules/map/bloc/map_bloc.dart @@ -92,6 +92,7 @@ class MapBloc extends Bloc { _observeLocationRequests(); _startObservingFcmToken(); emit(state.copyWith(mapInitialized: true)); + Logger.sendOnlineLogs(); } void _observeMyPosition() async { diff --git a/lib/repositories/location/sharing_location_repository.dart b/lib/repositories/location/sharing_location_repository.dart index d570deb..97dcf85 100644 --- a/lib/repositories/location/sharing_location_repository.dart +++ b/lib/repositories/location/sharing_location_repository.dart @@ -168,8 +168,8 @@ class FirestoreSharingLocationRepository implements SharingLocationRepository { } @override - Future uploadPosition(Position position, List sessions) { - Logger.log(_TAG, 'Uploading position to ${sessions.length} sessions'); + Future uploadPosition(Position position, List sessions) async { + await Logger.saveOnlineLog(_TAG, 'Uploading position to ${sessions.length} sessions'); List futures = []; for (var session in sessions) { final data = ShareData( @@ -180,7 +180,7 @@ class FirestoreSharingLocationRepository implements SharingLocationRepository { ); futures.add(_firestore.collection(FirestoreConstants.COLLECTION_SHARING_DATA).doc(data.sessionId).set(data.toJson())); } - return Future.value(); + await Future.wait(futures); } @override diff --git a/lib/services/http/loggly_service.dart b/lib/services/http/loggly_service.dart new file mode 100644 index 0000000..384fbeb --- /dev/null +++ b/lib/services/http/loggly_service.dart @@ -0,0 +1,24 @@ +// Copyright MeWe 2025. +// +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/. + +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'loggly_service.g.dart'; + +@RestApi() +abstract class LogglyService { + factory LogglyService(Dio dio, {String baseUrl}) = _LogglyService; + + /// Send logs in bulk to Loggly + /// Logs in jsonl format + @POST("bulk/{token}/tag/batch/") + Future sendLogsInBulk(@Path("token") String token, @Body() String logData); +} diff --git a/lib/services/location/location_sharing.dart b/lib/services/location/location_sharing.dart index cabec1c..0300f0d 100644 --- a/lib/services/location/location_sharing.dart +++ b/lib/services/location/location_sharing.dart @@ -18,7 +18,7 @@ import 'package:mewe_maps/utils/logger.dart'; const String _TAG = "shareMyLocationWithSessions"; Future shareMyLocationWithSessions({VoidCallback? onFinish}) async { - Logger.log(_TAG, "shareMyLocationWithSessions"); + await Logger.saveOnlineLog(_TAG, "shareMyLocationWithSessions"); final userId = StorageRepository.user?.userId; if (userId != null) { @@ -29,17 +29,17 @@ Future shareMyLocationWithSessions({VoidCallback? onFinish}) async { final sessions = await sharingRepository.getSharingSessionsAsOwner(userId); if (sessions != null && sessions.isNotEmpty) { await sharingRepository.uploadPosition(lastPosition, sessions); - Logger.log(_TAG, "success"); + await Logger.saveOnlineLog(_TAG, "success"); onFinish?.call(); return true; } else { - Logger.log(_TAG, "failed (no sessions)"); + await Logger.saveOnlineLog(_TAG, "failed (no sessions)"); } } else { - Logger.log(_TAG, "failed (no last position)"); + await Logger.saveOnlineLog(_TAG, "failed (no last position)"); } } else { - Logger.log(_TAG, "failed (no current user)"); + await Logger.saveOnlineLog(_TAG, "failed (no current user)"); } return false; } diff --git a/lib/services/workmanager/workmanager.dart b/lib/services/workmanager/workmanager.dart index 45f3a5a..c510cd4 100644 --- a/lib/services/workmanager/workmanager.dart +++ b/lib/services/workmanager/workmanager.dart @@ -26,7 +26,7 @@ void workmanagerCallback() async { await initializeIsolate(); Workmanager().executeTask((task, inputData) async { - Logger.log(_TAG, "executeTask $task"); + await Logger.saveOnlineLog(_TAG, "executeTask $task"); if (task == _PERIODIC_SHARE_LOCATION_TASK) { await shareMyLocationWithSessions(); @@ -38,7 +38,7 @@ void workmanagerCallback() async { Logger.log(_TAG, "Unknown task: $task"); } - return Future.value(true); + return Logger.sendOnlineLogs().then((_) => true); }); } @@ -79,5 +79,5 @@ Future registerPeriodicShareTaskOnAndroid() async { Future registerStopPreciseTrackingOnNoSessions() async { await Workmanager().registerOneOffTask(_STOP_TRACKING_NO_SESSIONS_TASK, _STOP_TRACKING_NO_SESSIONS_TASK); - Logger.log(_TAG, "registerStopPreciseTrackingOnNoSessions success"); + await Logger.saveOnlineLog(_TAG, "registerStopPreciseTrackingOnNoSessions success"); } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 18c32d6..d694d42 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -8,11 +8,34 @@ // // You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/. +import 'package:flutter/foundation.dart'; + +import 'loggly_logger.dart'; + class Logger { static bool LOG_DIO = true; static void log(String tag, String text) { - final pattern = RegExp('.{1,800}'); // 800 is the size of each chunk - pattern.allMatches(text).forEach((match) => print("MEWE_MAPS: $tag: ${match.group(0)}")); + if (kDebugMode) { + final pattern = RegExp('.{1,800}'); // 800 is the size of each chunk + pattern.allMatches(text).forEach((match) => print("MEWE_MAPS: $tag: ${match.group(0)}")); + } + } + + static Future saveOnlineLog(String tag, String text, {Map? params}) async { + try { + await LogglyLogger.instance.log(text, tag: tag, params: params); + } catch (e) { + log(tag, "LogglyLogger error: $e"); + } + log(tag, text); + } + + static Future sendOnlineLogs() async { + try { + await LogglyLogger.instance.sendPendingLogs(); + } catch (e) { + log("Logger", "Error sending logs to Loggly: $e"); + } } } diff --git a/lib/utils/loggly_cache.dart b/lib/utils/loggly_cache.dart new file mode 100644 index 0000000..324db3b --- /dev/null +++ b/lib/utils/loggly_cache.dart @@ -0,0 +1,54 @@ +// Copyright MeWe 2025. +// +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/. + +import 'dart:convert'; +import 'dart:io'; +import 'package:mewe_maps/models/loggly_log_entry.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:synchronized/synchronized.dart'; + +class LogglyCache { + static const _fileName = 'loggly_cache.jsonl'; + File? _logFile; + final Lock _lock = Lock(); + + Future _getLogFile() async { + final dir = await getApplicationDocumentsDirectory(); + _logFile = File('${dir.path}/$_fileName'); + return _logFile!; + } + + Future cacheLog(LogglyLogEntry log) async { + await _lock.synchronized(() async { + final file = _logFile ?? await _getLogFile(); + final logLine = '${jsonEncode(log.toJson())}\n'; + await file.writeAsString(logLine, mode: FileMode.append, flush: true); + }); + } + + Future> readCachedLogs() async { + return await _lock.synchronized(() async { + final file = await _getLogFile(); + if (!await file.exists()) return []; + + final lines = await file.readAsLines(); + return lines.toList(); + }); + } + + Future clearCache() async { + await _lock.synchronized(() async { + final file = _logFile ?? await _getLogFile(); + if (await file.exists()) { + await file.delete(); + } + }); + } +} diff --git a/lib/utils/loggly_logger.dart b/lib/utils/loggly_logger.dart new file mode 100644 index 0000000..2b8a1a2 --- /dev/null +++ b/lib/utils/loggly_logger.dart @@ -0,0 +1,90 @@ +// Copyright MeWe 2025. +// +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/. + +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:mewe_maps/models/loggly_log_entry.dart'; +import 'package:mewe_maps/repositories/storage/storage_repository.dart'; +import 'package:mewe_maps/services/http/loggly_service.dart'; +import 'package:mewe_maps/utils/logger.dart'; +import 'package:mewe_maps/utils/loggly_cache.dart'; + +const _TAG = "LogglyLogger"; + +void initializeLogglyLogger() { + LogglyLogger.instance.configure(token: dotenv.env["LOGGLY_TOKEN"], baseUrl: "http://logs-01.loggly.com/"); +} + +class LogglyLogger { + static final LogglyLogger instance = LogglyLogger._internal(); + + LogglyService? _logglyService; + String? _token; + final _logglyCache = LogglyCache(); + + LogglyLogger._internal(); + + void configure({required String? token, required String baseUrl, String? tag}) { + _token = token; + final dio = Dio(); + _logglyService = LogglyService(dio, baseUrl: baseUrl); + } + + Future log(String message, {String? tag, String level = 'info', Map? params}) { + final log = LogglyLogEntry( + timestamp: DateTime.now().toUtc().toIso8601String(), + level: level, + message: message, + userId: StorageRepository.user?.userId, + tags: _createTags(tag), + params: params, + ); + + return _logglyCache.cacheLog(log); + } + + Future sendPendingLogs() async { + try { + final logs = await _logglyCache.readCachedLogs(); + if (logs.isNotEmpty) { + // send logs in bulk (for 100 logs) + for (int i = 0; i < logs.length; i += 100) { + final bulk = logs.sublist(i, i + 100 > logs.length ? logs.length : i + 100).join("\n"); + await _logglyService?.sendLogsInBulk(_token!, bulk); + } + await _logglyCache.clearCache(); + } + Logger.log(_TAG, "Sent ${logs.length} logs to Loggly"); + } catch (e) { + Logger.log(_TAG, "Error sending logs to Loggly: $e"); + } + } + + List _createTags(String? tag) { + List tags = []; + if (tag != null) { + tags.add(tag); + } + if (Platform.isIOS) { + tags.add("ios"); + } else if (Platform.isAndroid) { + tags.add("android"); + } + if (kDebugMode) { + tags.add("debug"); + } else { + tags.add("release"); + } + return tags; + } +} diff --git a/pubspec.lock b/pubspec.lock index b812f11..d150a04 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -788,6 +788,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + url: "https://pub.dev" + source: hosted + version: "2.2.16" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" path_provider_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7567b07..408c44c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: firebase_core: ^3.12.0 cloud_firestore: ^5.6.4 firebase_messaging: ^15.2.4 + path_provider: ^2.1.1 dev_dependencies: flutter_test: