Build your own Logger in Dart and Flutter

Build your own Logger in Dart and Flutter
Photo by Artur Shamsutdinov / Unsplash

Logging is a fundamental process of recording messages and state of a program. These records are built with the intent of better understanding a program's execution. Logging involves taking in a message, and outputting a log. These logs can take many different shapes, files, databases, but the most common is a console output. Dart, Flutter, provide three methods for console output: print, debugPrint and log.

print

The print(Object?) function is the standard method for printing text to a console in a Dart program.  Takes in an Object, and makes a call to the Object's toString() function. This converts the Object into a String and sends its to the console logs.

debugPrint

The debugPrint(String? int?) function works exactly like the print(Object?) (it actually makes a call to the print function), except it throttles the output and auto-applies a new line if an int is passed to the wrapWidth argument.

debugPrint throttles messages to the console to prevent the native platforms from dropping messages. This will ensure all messages make it to the console.

The wrapWidth argument will configure the logs to be applied to multiple lines if the number of characters exceed the number from wrapWidth.

log

log is from the dart: developer package, and allows for more structured logs. It comes with some nice features as well: Messages can be time stamped, message tagging via name argument, there is a severity level argument, and it allows for Errors and StackTraces to be passed as arguments. The message tagging is very helpful when filtering logs from the DevTools application.

Creating a Logger Interface

First off, why create a Logger interface? Why not just put print, debugPrint or log where needed?

Logs should never almost never leak into production builds, especially console outputs. Recordings of what the app, or the user, is doing should never be openly available to be read by anyone. This could possibly expose the app, user, and a plethora sensitive information if you're not careful.

There might also be a requirement to send logs to multiple sources: files, remote apis, etc. Those sources could change depending on the build varient (release/debug).

Also, its not fun keeping track of if (kDebugMode) { statements through an entire application's codebase.

Defining Levels

To define a Logger interface, lets define the levels of our messages. Levels imply the severity of the log message. These levels can be interpreted however needed by the implementation of the Logger. Lets start the standard levels: Verbose, Info, Debug, Warning, and Error. Verbose being the least sever level, and Error the most sever.

abstract interface class Logger {

  /// Verbose level logging
  void v(String message, {
    String? tag,
    Exception? error,
    StackTrace? stacktrace
  });

  /// Information level logging
  void i(String message, {
    String? tag,
    Exception? error,
    StackTrace? stacktrace
  });

  /// Debug level logging.
  void d(String message, {
    String? tag,
    Exception? error,
    StackTrace? stacktrace
  });

  /// Warning level logging
  void w(String message, {
    String? tag,
    Exception? error,
    StackTrace? stacktrace
  });

  /// Error level logging
  void e(String message, {
    String? tag,
    Exception? error,
    StackTrace? stacktrace
  });
}

With the snippet above, we can see there is a method for each level: v for Verbose, i for Info, etc.

Building a Console Logger

With an interface defined, lets implement a console-based Logger class!

import 'dart:developer';

import 'package:flutter/foundation.dart';

import 'logger.dart';

const _debug = 'Debug';
const _error = 'Error';
const _info = 'Info';
const _verbose = 'Verbose';
const _warning = 'Warning';

final class ConsoleLogger implements Logger {

  const ConsoleLogger({required this.defaultTag}) 
      : assert(kDebugMode, "Console Logger shouldn't be used in release builds.");
  
  final String defaultTag;

  @override
  void d(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
    _log(
      level: _debug,
      message: message,
      tag: tag,
      error: error,
      stacktrace: stacktrace,
    );
  }

  @override
  void e(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
    _log(
      level: _error,
      message: message,
      tag: tag,
      error: error,
      stacktrace: stacktrace,
    );
  }

  @override
  void i(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
    _log(
      level: _info,
      message: message,
      tag: tag,
      error: error,
      stacktrace: stacktrace,
    );
  }

  @override
  void v(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
    _log(
      level: _verbose,
      message: message,
      tag: tag,
      error: error,
      stacktrace: stacktrace,
    );
  }

  @override
  void w(String message, {String? tag, Exception? error, StackTrace? stacktrace}) {
    _log(
      level: _warning, 
      message: message, 
      tag: tag, 
      error: error,
      stacktrace: stacktrace,
    );
  }

  void _log({
    required String level,
    required String message,
    String? tag,
    Exception? error,
    StackTrace? stacktrace
  }) {
    final time = DateTime.now();
    final buffer = StringBuffer('[$level]')
      ..write(' ')
      ..write('${time.hour}:')
      ..write('${time.minute}:')
      ..write('${time.second}:')
      ..write('${time.millisecond}')
      ..write(' - ')
      ..write(message);
    log(
      buffer.toString(), 
      time: time, 
      name: tag ?? defaultTag, 
      error: error, 
      stackTrace: stacktrace,
    );
  }
}

Okay, lets break down the snippet above!

const _debug = 'Debug';
const _error = 'Error';
const _info = 'Info';
const _verbose = 'Verbose';
const _warning = 'Warning';

final class ConsoleLogger implements Logger {
  ...

The const variables declared above are the values we will use to log each level.

...

import 'package:flutter/foundation.dart';

import 'logger.dart';

...

final class ConsoleLogger implements Logger {

  const ConsoleLogger({required this.defaultTag}) 
      : assert(kDebugMode, "Console Logger shouldn't be used in release builds.");
  
  final String defaultTag;
  
  ...

The name of the new Logger is called ConsoleLogger - pretty appropriate for what its purpose is!

Inside the ConsoleLogger class, you notice we have a defaultTag argument, and an assert block following the constructor.

The defaultTag will allow us to set a global tag for each log. Some logs don't have an origin that can be appropriately tagged, or sometimes I get a little lazy and just use the global tag.

The assert block is there to make sure the app does NOT use the ConsoleLogger in production. The kDebugMode variable comes from the flutter/foundation.dart package, and denotes when the app is a release/debug build. Its good to have checks like this to make sure the app does not build with faulty or debugging code.

 ...
 
 final class ConsoleLogger implements Logger {
 
  ...
  
  void _log({
    required String level,
    required String message,
    String? tag,
    Exception? error,
    StackTrace? stacktrace
  }) {
    final time = DateTime.now();
    final buffer = StringBuffer('[$level]')
      ..write(' ')
      ..write('${time.hour}:')
      ..write('${time.minute}:')
      ..write('${time.second}:')
      ..write('${time.millisecond}')
      ..write(' - ')
      ..write(message);
    log(
      buffer.toString(), 
      time: time, 
      name: tag ?? defaultTag, 
      error: error, 
      stackTrace: stacktrace,
    );
  }
}

The _log function is the core function to the ConsoleLogger class. All of the interfacing functions make a call to this function to send a message to the console. The function will timestamp when the message was received, and format the rest of the message. We have a StringBuffer to help write and format the message, then we use the log function from the dart:developer package.

Console Output

Lets see how the logs look in ConsoleLogger!

void main() {
  const logger = ConsoleLogger(defaultTag: 'Application');
  runApp(const MyWidget(log: logger));
}

class MyWidget extends StatelessWidget {
  const MyWidget({super.key, required this.log});

  static const _tag = 'MyWidget';

  final Logger log;

  @override
  Widget build(BuildContext context) {
    log.i('Building my widget...', tag: _tag);
    try {
      throw Exception('MyWidget is throwing an Exception!');
    } on Exception catch (exception, stacktrace) {
      log.e('Error occurred', tag: _tag, error: exception, stacktrace: stacktrace);
    }
    return const MaterialApp(
      home: Text('Hello, World!'),
    );
  }
}

Using the snippet above, lets see what the console looks like!

Performing hot reload...
Syncing files to device sdk gphone64 x86 64...
Reloaded 1 of 704 libraries in 246ms (compile: 20 ms, reload: 151 ms, reassemble: 49 ms).
[MyWidget] [Info] 20:56:24:575 - Building my widget...
[MyWidget] [Error] 20:56:24:575 - Error occurred
D/EGL_emulation(20678): app_time_stats: avg=4093.77ms min=11.69ms max=44815.84ms count=11
           Exception: MyWidget is throwing an Exception!
           #0      MyWidget.build (package:etm/main.dart:20:7)
           #1      StatelessElement.build (package:flutter/src/widgets/framework.dart:5550:49)
           #2      ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5480:15)
           #3      Element.rebuild (package:flutter/src/widgets/framework.dart:5196:7)
           #4      BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2904:19)
           #5      WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:989:21)
           #6      RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:448:5)
           #7      SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1386:15)
           #8      SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1311:9)
           #9      SchedulerBinding.scheduleWarmUpFrame.<anonymous closure> (package:flutter/src/scheduler/binding.dart:1034:7)
           #10     Timer._createTimer.<anonymous closure> (dart:async-patch/timer_patch.dart:18:15)
           #11     _Timer._runTimers (dart:isolate-patch/timer_impl.dart:398:19)
           #12     _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:429:5)
           #13     _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12)

Now, it looks like the app experienced a crash, but it didn't! The ConsoleLogger logged the Exception that was deliberately thrown from within the MyWidget class. As you can see, the format for this ConsoleLogger is something of the following:

[{TAG}] [{LEVEL}] {TIMESTAMP} - {MESSAGE}

Exceptions and Stacktraces will follow after the formatted message is printed in the console.

Conclusion

Now, in this project there is an interface Logger that can be provided anywhere in the code and controlled from the start of the main function! For example, we can check the kDebugMode variable and build a different Logger for release and debug builds.

void main() {
  Logger logger;
  if (kDebugMode) {
    logger = const ConsoleLogger(defaultTag: 'Application');
  } else {
    logger = ...
  }
  runApp(MyWidget(log: logger));
}

Code written through out this article can be found here.

Thank you for reading!