123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- library super_logging;
- import 'dart:async';
- import 'dart:collection';
- import 'dart:core';
- import 'dart:io';
- import 'package:flutter/foundation.dart';
- import 'package:flutter/widgets.dart';
- import 'package:http/http.dart' as http;
- import 'package:intl/intl.dart';
- import 'package:logging/logging.dart';
- import 'package:package_info_plus/package_info_plus.dart';
- import 'package:path/path.dart';
- import 'package:path_provider/path_provider.dart';
- import 'package:photos/core/error-reporting/tunneled_transport.dart';
- import 'package:photos/models/typedefs.dart';
- import 'package:sentry_flutter/sentry_flutter.dart';
- extension SuperString on String {
- Iterable<String> chunked(int chunkSize) sync* {
- var start = 0;
- while (true) {
- final stop = start + chunkSize;
- if (stop > length) break;
- yield substring(start, stop);
- start = stop;
- }
- if (start < length) {
- yield substring(start);
- }
- }
- }
- extension SuperLogRecord on LogRecord {
- String toPrettyString([String? extraLines]) {
- final header = "[$loggerName] [$level] [$time]";
- var msg = "$header $message";
- if (error != null) {
- msg += "\n⤷ type: ${error.runtimeType}\n⤷ error: $error";
- }
- if (stackTrace != null) {
- msg += "\n⤷ trace: $stackTrace";
- }
- for (var line in extraLines?.split('\n') ?? []) {
- msg += '\n$header $line';
- }
- return msg;
- }
- }
- class LogConfig {
- /// The DSN for a Sentry app.
- /// This can be obtained from the Sentry apps's "settings > Client Keys (DSN)" page.
- ///
- /// Only logs containing errors are sent to sentry.
- /// Errors can be caught using a try-catch block, like so:
- ///
- /// ```
- /// final logger = Logger("main");
- ///
- /// try {
- /// // do something dangerous here
- /// } catch(e, trace) {
- /// logger.info("Huston, we have a problem", e, trace);
- /// }
- /// ```
- ///
- /// If this is [null], Sentry logger is completely disabled (default).
- String? sentryDsn;
- String? tunnel;
- /// A built-in retry mechanism for sending errors to sentry.
- ///
- /// This parameter defines the time to wait for, before retrying.
- Duration sentryRetryDelay;
- /// Path of the directory where log files will be stored.
- ///
- /// If this is [null], file logging is completely disabled (default).
- ///
- /// If this is an empty string (['']),
- /// then a 'logs' directory will be created in [getTemporaryDirectory()].
- ///
- /// A non-empty string will be treated as an explicit path to a directory.
- ///
- /// The chosen directory can be accessed using [SuperLogging.logFile.parent].
- String? logDirPath;
- /// The maximum number of log files inside [logDirPath].
- ///
- /// One log file is created per day.
- /// Older log files are deleted automatically.
- int maxLogFiles;
- /// Whether to enable super logging features in debug mode.
- ///
- /// Sentry and file logging are typically not needed in debug mode,
- /// where a complete logcat is available.
- bool enableInDebugMode;
- /// If provided, super logging will invoke this function, and
- /// any uncaught errors during its execution will be reported.
- ///
- /// Works by using [FlutterError.onError] and [runZoned].
- FutureOrVoidCallback? body;
- /// The date format for storing log files.
- ///
- /// `DateFormat('y-M-d')` by default.
- DateFormat? dateFmt;
- String prefix;
- LogConfig({
- this.sentryDsn,
- this.tunnel,
- this.sentryRetryDelay = const Duration(seconds: 30),
- this.logDirPath,
- this.maxLogFiles = 10,
- this.enableInDebugMode = false,
- this.body,
- this.dateFmt,
- this.prefix = "",
- }) {
- dateFmt ??= DateFormat("y-M-d");
- }
- }
- class SuperLogging {
- /// The logger for SuperLogging
- static final $ = Logger('ente_logging');
- /// The current super logging configuration
- static late LogConfig config;
- static Future<void> main([LogConfig? appConfig]) async {
- appConfig ??= LogConfig();
- SuperLogging.config = appConfig;
- WidgetsFlutterBinding.ensureInitialized();
- appVersion ??= await getAppVersion();
- final isFDroidClient = await isFDroidBuild();
- if (isFDroidClient) {
- appConfig.sentryDsn = null;
- appConfig.tunnel = null;
- }
- final enable = appConfig.enableInDebugMode || kReleaseMode;
- sentryIsEnabled = enable && appConfig.sentryDsn != null && !isFDroidClient;
- fileIsEnabled = enable && appConfig.logDirPath != null;
- if (fileIsEnabled) {
- await setupLogDir();
- }
- if (sentryIsEnabled) {
- setupSentry();
- }
- Logger.root.level = Level.ALL;
- Logger.root.onRecord.listen(onLogRecord);
- if (isFDroidClient) {
- assert(
- sentryIsEnabled == false,
- "sentry dsn should be disabled for "
- "f-droid config ${appConfig.sentryDsn} & ${appConfig.tunnel}",
- );
- }
- if (!enable) {
- $.info("detected debug mode; sentry & file logging disabled.");
- }
- if (fileIsEnabled) {
- $.info("log file for today: $logFile with prefix ${appConfig.prefix}");
- }
- if (sentryIsEnabled) {
- $.info("sentry uploader started");
- }
- if (appConfig.body == null) return;
- if (enable && sentryIsEnabled) {
- await SentryFlutter.init(
- (options) {
- options.dsn = appConfig!.sentryDsn;
- options.httpClient = http.Client();
- if (appConfig.tunnel != null) {
- options.transport =
- TunneledTransport(Uri.parse(appConfig.tunnel!), options);
- }
- },
- appRunner: () => appConfig!.body!(),
- );
- } else {
- await appConfig.body!();
- }
- }
- static void setUserID(String userID) async {
- if (config.sentryDsn != null) {
- Sentry.configureScope((scope) => scope.user = SentryUser(id: userID));
- $.info("setting sentry user ID to: $userID");
- }
- }
- static Future<void> _sendErrorToSentry(
- Object error,
- StackTrace? stack,
- ) async {
- try {
- await Sentry.captureException(
- error,
- stackTrace: stack,
- );
- } catch (e) {
- $.info('Sending report to sentry.io failed: $e');
- $.info('Original error: $error');
- }
- }
- static String _lastExtraLines = '';
- static Future onLogRecord(LogRecord rec) async {
- // log misc info if it changed
- String? extraLines = "app version: '$appVersion'\n";
- if (extraLines != _lastExtraLines) {
- _lastExtraLines = extraLines;
- } else {
- extraLines = null;
- }
- final str = (config.prefix) + " " + rec.toPrettyString(extraLines);
- // write to stdout
- printLog(str);
- // push to log queue
- if (fileIsEnabled) {
- fileQueueEntries.add(str + '\n');
- if (fileQueueEntries.length == 1) {
- flushQueue();
- }
- }
- // add error to sentry queue
- if (sentryIsEnabled && rec.error != null) {
- _sendErrorToSentry(rec.error!, null);
- }
- }
- static final Queue<String> fileQueueEntries = Queue();
- static bool isFlushing = false;
- static void flushQueue() async {
- if (isFlushing || logFile == null) {
- return;
- }
- isFlushing = true;
- final entry = fileQueueEntries.removeFirst();
- await logFile!.writeAsString(entry, mode: FileMode.append, flush: true);
- isFlushing = false;
- if (fileQueueEntries.isNotEmpty) {
- flushQueue();
- }
- }
- // Logs on must be chunked or they get truncated otherwise
- // See https://github.com/flutter/flutter/issues/22665
- static var logChunkSize = 800;
- static void printLog(String text) {
- text.chunked(logChunkSize).forEach(print);
- }
- /// A queue to be consumed by [setupSentry].
- static final sentryQueueControl = StreamController<Error>();
- /// Whether sentry logging is currently enabled or not.
- static bool sentryIsEnabled = false;
- static Future<void> setupSentry() async {
- await for (final error in sentryQueueControl.stream.asBroadcastStream()) {
- try {
- Sentry.captureException(
- error,
- );
- } catch (e) {
- $.fine(
- "sentry upload failed; will retry after ${config.sentryRetryDelay}",
- );
- doSentryRetry(error);
- }
- }
- }
- static void doSentryRetry(Error error) async {
- await Future.delayed(config.sentryRetryDelay);
- sentryQueueControl.add(error);
- }
- /// The log file currently in use.
- static File? logFile;
- /// Whether file logging is currently enabled or not.
- static bool fileIsEnabled = false;
- static Future<void> setupLogDir() async {
- var dirPath = config.logDirPath;
- // choose [logDir]
- if (dirPath == null || dirPath.isEmpty) {
- final root = await getExternalStorageDirectory();
- dirPath = '${root!.path}/logs';
- }
- // create [logDir]
- final dir = Directory(dirPath);
- await dir.create(recursive: true);
- final files = <File>[];
- final dates = <File, DateTime>{};
- // collect all log files with valid names
- await for (final file in dir.list()) {
- try {
- final date = config.dateFmt!.parse(basename(file.path));
- dates[file as File] = date;
- files.add(file);
- } on FormatException {}
- }
- final nowTime = DateTime.now();
- // delete old log files, if [maxLogFiles] is exceeded.
- if (files.length > config.maxLogFiles) {
- // sort files based on ascending order of date (older first)
- files.sort(
- (a, b) => (dates[a] ?? nowTime).compareTo((dates[b] ?? nowTime)),
- );
- final extra = files.length - config.maxLogFiles;
- final toDelete = files.sublist(0, extra);
- for (final file in toDelete) {
- try {
- $.fine(
- "deleting log file ${file.path}",
- );
- await file.delete();
- } catch (ignore) {}
- }
- }
- logFile = File("$dirPath/${config.dateFmt!.format(DateTime.now())}.txt");
- }
- /// Current app version, obtained from package_info plugin.
- ///
- /// See: [getAppVersion]
- static String? appVersion;
- static Future<String> getAppVersion() async {
- final pkgInfo = await PackageInfo.fromPlatform();
- return "${pkgInfo.version}+${pkgInfo.buildNumber}";
- }
- // disable sentry on f-droid. We need to make it opt-in preference
- static Future<bool> isFDroidBuild() async {
- if (!Platform.isAndroid) {
- return false;
- }
- final pkgName = (await PackageInfo.fromPlatform()).packageName;
- return pkgName.startsWith("io.ente.photos.fdroid");
- }
- }
|