As a kind of follow up to Tips on making Dart & Flutter projects more easily debuggable, I’d like to ask everyone about how exactly they log in their production projects.
Which library do you use? When and how do you set up the main logger instance? How do you inject the logger into the rest of your app? How do you output the logs? Which service(s) do you use to get a hold of the logs from your users?
To kick things out, I’ll start with what I’m currently doing in GIANT ROBOT GAME. (This one’s a bit specific in that it’s mainly a desktop project, and a game, so take it with a healthy grain of salt.)
-
I just use the barebones
pkg:logging
. -
I have a template set up in intellij for quickly adding a logger to any class. I just write
logger
and it’ll write something likestatic final Logger _log = Logger('WeaponPart');
. That way I don’t introduce basically any dependency, I don’t need to inject stuff, etc. -
I log at all levels between
finest
andsevere
. I try to log pretty extensively and often useif
statements with logs instead of asserts so that things are caught even in production. -
For logs with levels
warning
and above, I try my best to include stacktrace (so that it’s easier for me to debug). For example:if (startingTile == null) { _log.warning('Trying to compute traversed tiles from outside the map', ArgumentError('$origin out of bounds', 'origin'), StackTrace.current); return; }
-
I have the game set up so that the in-game console opens up any time a log with the level
warning
or above opens when debugging. That way, I’m always aware when things go awry. -
In production, warnings are merely reported via Sentry but don’t open the in-game console. The in-game console gets open only on log messages I tag as
severe
. -
In main, I have the following code (I’m adding some additional explanatory line comments with
// *
):
Future<void> main() async {
if (kDebugMode) {
debugPrint("WARNING: Sentry is disabled because we're in debug mode. "
"Sentry functionality (such as sending feedback) will fail silently.");
await _guardedMain();
return;
}
await SentryFlutter.init(
(options) {
options.dsn = '...';
if (const bool.hasEnvironment('devName')) {
// Run with --dart-define=devName=yourname in debug mode.
const devName = String.fromEnvironment('devName');
options.environment = 'debug-$devName';
debugPrint(options.environment);
}
options.release = 'giant_robot@$appVersion+$appVersionLabel'; // *So, in sentry, I get something like "giant_robot@0.25+55+Xenotech". The version label helps me more easily distinguish one version from another.
options.beforeSend = (event, hint) {
if (hint.get('isPlayerFeedback') == true) {
// Keep different player feedback separated (prevent sentry from
// grouping them together).
event = event.copyWith(
fingerprint: [
'{{ default }}',
hint.get('feedbackText').toString(),
],
);
}
return event;
};
},
appRunner: _guardedMain,
);
}
/// The initialization code that would normally be in [main]
/// but it's wrapped with [SentryFlutter.init] for error reporting.
Future<void> _guardedMain() async {
final log = Logger('_guardedMain');
// Needed for `package:path_provider` to work, which in turn is needed
// for [IoFileLogger] to work.
WidgetsFlutterBinding.ensureInitialized();
final FileLogger fileLogger; // *On desktop, it's a good idea to keep your own log file somewhere.
if (kIsWeb) {
fileLogger = FileLogger.noOp();
} else {
fileLogger = IoFileLogger();
}
await fileLogger.initialize();
Logger.root.level = Level.FINE; // *Defaults to Level.INFO, but at least at this stage, I want the Level.FINE logs, too.
Logger.root.onRecord.listen((record) {
dev.log(
record.message,
time: record.time,
level: record.level.value,
name: record.loggerName,
error: record.error,
stackTrace: record.stackTrace,
);
final logLevelTag = '[${record.level.name}]';
fileLogger.writeln('${record.time.toIso8601String()} '
'${logLevelTag.padLeft(9)} '
'<${record.loggerName}> '
'${record.message}');
if (record.error != null || record.stackTrace != null) {
fileLogger.writeln('[ERROR]: ${record.error}');
fileLogger.writeln('[STACKTRACE]: ${record.stackTrace}');
}
// Add the log to Sentry so that we have that context in case of an error.
Sentry.addBreadcrumb(
Breadcrumb(
message: record.message,
timestamp: record.time.toUtc(),
category: record.loggerName,
level: switch (record.level) {
Level.ALL => SentryLevel.debug,
Level.FINEST => SentryLevel.debug,
Level.FINER => SentryLevel.debug,
Level.FINE => SentryLevel.debug,
Level.CONFIG => SentryLevel.debug,
Level.INFO => SentryLevel.info,
Level.WARNING => SentryLevel.warning,
Level.SEVERE => SentryLevel.error,
Level.SHOUT => SentryLevel.fatal,
Level.OFF => SentryLevel.info,
_ => SentryLevel.warning,
},
),
);
if (record.level >= Level.WARNING) {
Sentry.captureException(_ExceptionFromLog(record),
stackTrace: record.stackTrace);
}
});
// The in-game console which can be opened by hitting `/` (slash).
// It starts listening to log messages as soon as it's created.
final console = Console();
// We need to wait until after Console is created so that the message
// makes it into the console log.
log.info(() => 'Logging to $fileLogger.');
... // *Other initialization, now logged.
runApp( ... );
}
For the record, I don’t think this is the best approach and I don’t even think this is a particularly good approach. It’s been very helpful so far but I’m looking for improvements and other ideas.