20  Serious Logging

I’ll take you through a comprehensive journey of the logging package, building from the foundation up to advanced configurations. Since you’re coming from Python, you’ll find many concepts familiar - the Dart logging package was actually inspired by Python’s logging module and Java’s java.util.logging.

20.1 Understanding the Core Concepts

Before we dive into code, let’s understand the mental model of how the logging package works. Imagine a tree structure where each logger is a node. At the root of this tree sits the root logger, and all other loggers are its descendants. This hierarchical structure allows for powerful configuration inheritance.

Logger.root
├── Logger('MyApp')
│   ├── Logger('MyApp.UI')
│   │   ├── Logger('MyApp.UI.HomePage')
│   │   └── Logger('MyApp.UI.SettingsPage')
│   └── Logger('MyApp.Services')
│       ├── Logger('MyApp.Services.API')
│       └── Logger('MyApp.Services.Database')
└── Logger('ThirdPartyLib')

20.2 Step 1: Basic Initialization

Let’s start with the simplest possible setup and gradually add complexity:

import 'package:logging/logging.dart';

void main() {
  // Step 1: Enable logging globally
  // By default, logging is set to Level.INFO and above
  Logger.root.level = Level.ALL; // This enables all log levels
  
  // Step 2: Set up a listener to actually do something with the logs
  // Without this, logs are generated but go nowhere!
  Logger.root.onRecord.listen((LogRecord record) {
    print('${record.level.name}: ${record.time}: ${record.message}');
  });
  
  // Step 3: Create a logger and use it
  final logger = Logger('MyFirstLogger');
  logger.info('Hello from logging!');
  
  runApp();
}

The key insight here is that logging has two parts: generating log records and consuming them. The logger generates records, but without a listener, nothing happens with them.

20.3 Step 2: Understanding Logger Hierarchy

One of the most powerful features is the hierarchical nature of loggers. Let’s explore this:

void demonstrateHierarchy() {
  // Configure the root logger
  Logger.root.level = Level.INFO;
  Logger.root.onRecord.listen((record) {
    print('[${record.level.name}] ${record.loggerName}: ${record.message}');
  });
  
  // Create a parent logger
  final appLogger = Logger('MyApp');
  
  // Create child loggers using dot notation
  final uiLogger = Logger('MyApp.UI');
  final apiLogger = Logger('MyApp.API');
  final dbLogger = Logger('MyApp.Database');
  
  // All these loggers inherit configuration from their parents
  appLogger.info('App starting');        // Will print
  uiLogger.info('UI initialized');       // Will print
  apiLogger.fine('API details');         // Won't print (FINE < INFO)
  dbLogger.warning('DB connection slow'); // Will print
  
  // You can override levels for specific loggers
  apiLogger.level = Level.ALL; // Now this logger logs everything
  apiLogger.fine('API details');         // Now this will print!
}

This hierarchy allows you to control logging granularity. For example, you might want detailed logs from your API module during debugging but only warnings from the UI module.

20.4 Step 3: Comprehensive Configuration

Let’s create a robust logging configuration that you might use in a real application:

import 'package:logging/logging.dart';
import 'package:flutter/foundation.dart';
import 'dart:io';

class LoggingConfig {
  static final _logFile = File('app_logs.txt');
  static IOSink? _logSink;
  
  /// Initialize logging based on the current environment
  static void initialize({
    bool verbose = false,
    bool logToFile = false,
  }) {
    // Determine base level based on build mode
    Level baseLevel;
    if (kDebugMode) {
      baseLevel = verbose ? Level.ALL : Level.INFO;
    } else if (kProfileMode) {
      baseLevel = Level.INFO;
    } else {
      // Release mode - only log warnings and above
      baseLevel = Level.WARNING;
    }
    
    Logger.root.level = baseLevel;
    
    // Clear any existing listeners to avoid duplicates
    Logger.root.clearListeners();
    
    // Set up console output
    Logger.root.onRecord.listen((record) {
      _handleLogRecord(record, logToFile: logToFile);
    });
    
    // Configure specific loggers if needed
    _configureSpecificLoggers();
  }
  
  static void _handleLogRecord(LogRecord record, {bool logToFile = false}) {
    // Format the timestamp
    final time = record.time.toIso8601String().substring(11, 23);
    
    // Color coding for different levels (works in most terminals)
    final levelColor = _getLevelColor(record.level);
    final resetColor = '\x1B[0m';
    
    // Format the log message
    final formattedMessage = _formatMessage(
      time: time,
      level: record.level,
      logger: record.loggerName,
      message: record.message,
      error: record.error,
      stackTrace: record.stackTrace,
    );
    
    // Output to console with color
    if (kDebugMode) {
      print('$levelColor$formattedMessage$resetColor');
    } else {
      // In release, use stdout/stderr appropriately
      if (record.level >= Level.SEVERE) {
        stderr.writeln(formattedMessage);
      } else {
        stdout.writeln(formattedMessage);
      }
    }
    
    // Optionally write to file
    if (logToFile) {
      _writeToFile(formattedMessage);
    }
    
    // In release mode, send errors to crash reporting
    if (!kDebugMode && record.level >= Level.SEVERE) {
      _sendToCrashReporting(record);
    }
  }
  
  static String _formatMessage({
    required String time,
    required Level level,
    required String logger,
    required String message,
    Object? error,
    StackTrace? stackTrace,
  }) {
    final buffer = StringBuffer();
    
    // Basic format: TIME [LEVEL] LoggerName: Message
    buffer.write('$time [${level.name.padRight(7)}] ');
    buffer.write('${logger.padRight(20)}: ');
    buffer.write(message);
    
    // Add error information if present
    if (error != null) {
      buffer.write('\n  Error: $error');
    }
    
    // Add stack trace if present (indented for readability)
    if (stackTrace != null) {
      buffer.write('\n  Stack trace:\n');
      final stackLines = stackTrace.toString().split('\n');
      for (final line in stackLines.take(10)) { // Limit stack trace length
        buffer.write('    $line\n');
      }
      if (stackLines.length > 10) {
        buffer.write('    ... ${stackLines.length - 10} more lines ...\n');
      }
    }
    
    return buffer.toString();
  }
  
  static String _getLevelColor(Level level) {
    if (level >= Level.SEVERE) return '\x1B[31m';  // Red
    if (level >= Level.WARNING) return '\x1B[33m'; // Yellow
    if (level >= Level.INFO) return '\x1B[32m';    // Green
    if (level >= Level.CONFIG) return '\x1B[36m';  // Cyan
    return '\x1B[37m'; // White for debug levels
  }
  
  static void _configureSpecificLoggers() {
    // Example: Make API logger more verbose during development
    if (kDebugMode) {
      Logger('MyApp.API').level = Level.ALL;
    }
    
    // Example: Silence a chatty third-party library
    Logger('NoisyLibrary').level = Level.WARNING;
  }
  
  static void _writeToFile(String message) {
    try {
      _logSink ??= _logFile.openWrite(mode: FileMode.append);
      _logSink!.writeln(message);
    } catch (e) {
      // If file writing fails, don't crash the app
      print('Failed to write to log file: $e');
    }
  }
  
  static void _sendToCrashReporting(LogRecord record) {
    // This would integrate with your crash reporting service
    // Example: Sentry, Crashlytics, etc.
    
    // For now, we'll just print a message
    print('Would send to crash reporting: ${record.message}');
  }
  
  /// Clean up resources
  static Future<void> dispose() async {
    await _logSink?.flush();
    await _logSink?.close();
  }
}

20.5 Step 4: Creating a Logger Mixin for Easy Use

To make logging easier throughout your application, create a mixin that provides logging functionality:

/// Mixin that provides logging functionality to any class
mixin LoggingMixin {
  /// Gets a logger named after the current class
  Logger get logger => Logger(runtimeType.toString());
  
  /// Convenience methods that mirror the logger's methods
  void logFinest(String message) => logger.finest(message);
  void logFiner(String message) => logger.finer(message);
  void logFine(String message) => logger.fine(message);
  void logConfig(String message) => logger.config(message);
  void logInfo(String message) => logger.info(message);
  void logWarning(String message, [Object? error, StackTrace? stackTrace]) {
    logger.warning(message, error, stackTrace);
  }
  void logSevere(String message, [Object? error, StackTrace? stackTrace]) {
    logger.severe(message, error, stackTrace);
  }
}

// Usage example
class UserService with LoggingMixin {
  Future<User?> fetchUser(String id) async {
    logInfo('Fetching user with id: $id');
    
    try {
      logFine('Making API request to /users/$id');
      // Simulate API call
      await Future.delayed(Duration(seconds: 1));
      
      if (id == 'invalid') {
        throw Exception('User not found');
      }
      
      final user = User(id: id, name: 'John Doe');
      logInfo('Successfully fetched user: ${user.name}');
      return user;
      
    } catch (e, stackTrace) {
      logSevere('Failed to fetch user $id', e, stackTrace);
      return null;
    }
  }
}

20.6 Step 5: Advanced Patterns and Best Practices

Let’s explore some advanced patterns that make logging more powerful:

/// A structured logging approach using custom log records
class StructuredLogger {
  final Logger _logger;
  final Map<String, dynamic> _context = {};
  
  StructuredLogger(String name) : _logger = Logger(name);
  
  /// Add persistent context that will be included in all logs
  void addContext(String key, dynamic value) {
    _context[key] = value;
  }
  
  /// Log with additional structured data
  void logStructured({
    required Level level,
    required String message,
    Map<String, dynamic>? data,
    Object? error,
    StackTrace? stackTrace,
  }) {
    // Combine persistent context with provided data
    final fullData = {..._context, ...?data};
    
    // Create a structured message
    final structuredMessage = StringBuffer(message);
    
    if (fullData.isNotEmpty) {
      structuredMessage.write(' | ');
      fullData.forEach((key, value) {
        structuredMessage.write('$key=$value ');
      });
    }
    
    _logger.log(level, structuredMessage.toString(), error, stackTrace);
  }
  
  // Convenience methods
  void info(String message, {Map<String, dynamic>? data}) {
    logStructured(level: Level.INFO, message: message, data: data);
  }
  
  void error(String message, Object error, StackTrace stackTrace, 
             {Map<String, dynamic>? data}) {
    logStructured(
      level: Level.SEVERE, 
      message: message, 
      data: data,
      error: error,
      stackTrace: stackTrace,
    );
  }
}

// Usage of structured logging
void demonstrateStructuredLogging() {
  final logger = StructuredLogger('PaymentService');
  
  // Add persistent context
  logger.addContext('service_version', '2.1.0');
  logger.addContext('environment', 'production');
  
  // Log with additional data
  logger.info('Processing payment', data: {
    'user_id': '12345',
    'amount': 99.99,
    'currency': 'USD',
    'payment_method': 'credit_card',
  });
  
  // This will output something like:
  // Processing payment | service_version=2.1.0 environment=production user_id=12345 amount=99.99 currency=USD payment_method=credit_card
}

20.7 Step 6: Integration with Flutter

Here’s how to properly integrate logging into a Flutter application:

import 'package:flutter/material.dart';
import 'package:logging/logging.dart';

void main() {
  // Initialize logging before running the app
  LoggingConfig.initialize(
    verbose: true, // Set based on your needs
    logToFile: false, // Enable for debugging
  );
  
  // Set up Flutter error handling
  FlutterError.onError = (FlutterErrorDetails details) {
    final logger = Logger('FlutterError');
    logger.severe(
      'Flutter error occurred',
      details.exception,
      details.stack,
    );
  };
  
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> with LoggingMixin {
  @override
  void initState() {
    super.initState();
    logInfo('MyApp initialized');
  }
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Logging Demo',
      home: LoggingDemoScreen(),
    );
  }
  
  @override
  void dispose() {
    logInfo('MyApp disposed');
    LoggingConfig.dispose(); // Clean up logging resources
    super.dispose();
  }
}

class LoggingDemoScreen extends StatelessWidget with LoggingMixin {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Logging Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => _simulateUserAction(),
              child: Text('Simulate User Action'),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () => _simulateError(),
              child: Text('Simulate Error'),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.red,
              ),
            ),
          ],
        ),
      ),
    );
  }
  
  void _simulateUserAction() {
    logInfo('User pressed action button');
    logFine('Starting simulated action...');
    
    // Simulate some work
    Future.delayed(Duration(seconds: 1), () {
      logInfo('Action completed successfully');
    });
  }
  
  void _simulateError() {
    logWarning('User pressed error simulation button');
    
    try {
      throw StateError('This is a simulated error for demonstration');
    } catch (e, stackTrace) {
      logSevere('Simulated error occurred', e, stackTrace);
    }
  }
}

20.8 Testing Your Logging Configuration

It’s important to test that your logging is working correctly. Here’s a simple test utility:

void testLoggingConfiguration() {
  final testLogger = Logger('LoggingTest');
  
  print('\n=== Testing Logging Configuration ===\n');
  
  // Test each level
  testLogger.finest('This is a FINEST message - most detailed');
  testLogger.finer('This is a FINER message - quite detailed');
  testLogger.fine('This is a FINE message - debugging info');
  testLogger.config('This is a CONFIG message - configuration');
  testLogger.info('This is an INFO message - general info');
  testLogger.warning('This is a WARNING message - potential issue');
  testLogger.severe('This is a SEVERE message - serious problem');
  testLogger.shout('This is a SHOUT message - highest priority');
  
  // Test error logging
  try {
    throw FormatException('Test exception');
  } catch (e, stack) {
    testLogger.severe('Caught an exception', e, stack);
  }
  
  print('\n=== Logging Test Complete ===\n');
}

20.9 Key Takeaways

The logging package gives you production-grade logging capabilities. Remember these key points:

  1. Always set up a listener - Without onRecord.listen(), your logs go nowhere
  2. Use the hierarchy - Name your loggers with dots to create a logical structure
  3. Configure by environment - Different log levels for debug, profile, and release
  4. Structure your logs - Include context and metadata for better debugging
  5. Don’t log sensitive data - Be careful about logging passwords, tokens, or personal information
  6. Clean up resources - Close file handles and flush buffers when done

The power of this logging system becomes apparent when you’re debugging production issues. With proper logging, you can trace exactly what happened, when it happened, and in what context - turning mysterious bugs into solvable problems.