How To Implement Observable Flutter Logging
Easiest way to implement flutter app observable logging.
There are several logging solutions from the flutter community, however none are observable in that they are not using the Flutter SDK default reactive streaming APIs. My approach uses a package called logging from the Flutter SDK team and the logging appenders package from the flutter community.
And my approach allows plugging into third party observable logging service providers and even to use colored console appenders.
Why Do We Need Observable Logging?
Observable logging is always app on logging that often is reported to a third party observable logging service.
We have two problems with most logging packages from the flutter community.
One, they are not using the stream API in the flutter SDK, which means they are not fast enough to be run in the non-debug situation of a released application. Two, they do not have good API structures to plug into different ways to do logging appenders.
The solution in one part comes from the Dart SDK and the appenders part comes from the flutter community in the form of these two packages:
Logging Package from Dart SDK Team
https://pub.dev/packages/logging
Logging Appenders Package from Codeux
https://pub.dev/packages/logging_appenders
But, what Dart structure can we us to initialize the settings for both the Logging and Logging Appenders packages?
Using Dart Singleton To Initialize Logging And Appenders
Okay, first let me show the different ways we can create singletons:
// fancy constructor way | |
class SingletonOne { | |
SingletonOne._privateConstructor(); | |
static final SingletonOne _instance = SingletonOne._privateConstructor(); | |
factory SingletonOne() { | |
return _instance; | |
} | |
} | |
// static field with getter | |
class SingletonTwo { | |
SingletonTwo._privateConstructor(); | |
static final SingletonTwo _instance = SingletonTwo._privateConstructor(); | |
static SingletonTwo get instance => _instance; | |
} | |
// static field | |
class SingletonThree { | |
SingletonThree._privateConstructor(); | |
static final SingletonThree instance = SingletonThree._privateConstructor(); | |
} |
For the needs to initialize, we probably should combine all these ways to construct a singleton together. Let's see how I use this and why:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// ignore_for_file: unused_local_variable | |
import 'dart:developer'; | |
import 'package:logging/logging.dart'; | |
import 'package:logging_appenders/logging_appenders.dart'; | |
/// The app Global logger. | |
final appLogger = Logger("LoggingDemo"); | |
/// AppLogging class initializes the logging set up at object creation. | |
/// To call it is just AppLogging() within the main function block. | |
/// | |
/// Note, this an obseverable log that operates in all modes including | |
/// release which means we do not use print but instead use log to output. | |
/// | |
/// @author Fredrick Allan Grott. | |
class AppLogging { | |
factory AppLogging() { | |
return _appLogging; | |
} | |
AppLogging._internal() { | |
_init(); | |
} | |
static final AppLogging _appLogging = AppLogging._internal(); | |
void _init() { | |
// disable hierarchical logger | |
hierarchicalLoggingEnabled = false; | |
Logger.root.level = Level.ALL; | |
// stack traces are expensive so we turn this on for | |
// severe and above | |
recordStackTraceAtLevel = Level.SEVERE; | |
// just so during a log level change that we know about it. | |
// log is used instead of print as this is for observable | |
// logging even in release mode | |
Logger.root.onLevelChanged.listen((event) { | |
log('${event?.name} changed'); | |
}); | |
// now to get the log output | |
Logger.root.onRecord.listen((record) { | |
if (record.error != null && record.stackTrace != null) { | |
log( | |
'${record.level.name}: ${record.loggerName}: ${record.time}: ${record.message}: ${record.error}: ${record.stackTrace}', | |
); | |
log( | |
'level: ${record.level.name} loggerName: ${record.loggerName} time: ${record.time} message: ${record.message} error: ${record.error} exception: ${record.stackTrace}', | |
); | |
} else if (record.error != null) { | |
log( | |
'level: ${record.level.name} loggerName: ${record.loggerName} time: ${record.time} message: ${record.message} error: ${record.error}', | |
); | |
} else { | |
log( | |
'level: ${record.level.name} loggerName: ${record.loggerName} time: ${record.time} message: ${record.message}', | |
); | |
} | |
}); | |
// Appenders set up for color logs | |
PrintAppender(formatter: const ColorFormatter()).attachToLogger(Logger.root); | |
// Any appendeing logs to 3rd party observable logging services | |
// would be here using the logging appenders other appenders | |
} | |
} |
The appLogger sets up a global logger with the app name as the string log tag. The factory returns the private appLogging field. The appLogging field points to the private constructor that initializes another private method named init.
The first part of the private init method is setting the settings to use the Logging package. I use a twist in mine in that I also record the logger changes with this:
// just so during a log level change that we know about it. | |
// log is used instead of print as this is for observable | |
// logging even in release mode | |
Logger.root.onLevelChanged.listen((event) { | |
log('${event?.name} changed'); | |
}); |
So if I change the level of the logger than it will be logger. Since I am implementing observable logging I have to use log instead of debugPrint and debugPrint is stripped out of release built flutter applications by the SDK.
In the main part of setting up the Logging package settings listener:
// now to get the log output | |
Logger.root.onRecord.listen((record) { | |
if (record.error != null && record.stackTrace != null) { | |
log( | |
'${record.level.name}: ${record.loggerName}: ${record.time}: ${record.message}: ${record.error}: ${record.stackTrace}', | |
); | |
log( | |
'level: ${record.level.name} loggerName: ${record.loggerName} time: ${record.time} message: ${record.message} error: ${record.error} exception: ${record.stackTrace}', | |
); | |
} else if (record.error != null) { | |
log( | |
'level: ${record.level.name} loggerName: ${record.loggerName} time: ${record.time} message: ${record.message} error: ${record.error}', | |
); | |
} else { | |
log( | |
'level: ${record.level.name} loggerName: ${record.loggerName} time: ${record.time} message: ${record.message}', | |
); | |
} | |
}); |
I have to use if statements to check whether record error or record stacktrace exist and format the log output accordingly.
And, the last part is the color log console appender settings I use:
// Appenders set up for color logs | |
PrintAppender(formatter: const ColorFormatter()).attachToLogger(Logger.root); |
Any logging to a third party logging service would go below that line. For example, for the Logz IO service I would have to add:
final _logzIoApiSender = LogzIoApiAppender( | |
// you can find your API key and the required URL from | |
// the logz.io dashboard: https://app-eu.logz.io/#/dashboard/settings/general | |
apiToken: 'MY API KEY', | |
url: 'https://listener-eu.logz.io:8071/', | |
labels: {'app': 'MyApp', 'os': Platform.operatingSystem}, | |
); | |
_logzIoApiSender.attachToLogger(Logger.root); |
Thoughts
That is another devOPS step completed in a typical flutter app project. If I know that it will not take up much memory, my go-to construct to use to initialize something with settings is a Dart singleton.
Fred Grott's Newsletter
Code:
https://github.com/fredgrott/flutter_bytes_hub
Demos-at-YouTube:
https://www.youtube.com/channel/UCRQadYlHQ8DKRQ_WwUrfZ_w)
An ADHD'ers super focus on Flutter app design and the side hustle of designing flutter apps. Delivered, several days every weekday to your inbox.