Files
eryao/.trellis/spec/frontend/logging-guidelines.md
T

11 KiB

Logging Guidelines

How logging is done in Flutter app.


Overview

This app uses structured logging with custom Logger class:

  • Library: Custom Logger class in core/logging/
  • Interface: getLogger(module) from core/logging/logger.dart
  • Log levels: debug, info, warning, error
  • Sensitive fields: Never log passwords, tokens, PII

Logger Setup

Import and Initialize

import 'core/logging/logger.dart';

class SomeBloc extends ChangeNotifier {
  final Logger _logger = getLogger('features.auth.bloc');
}

Module Naming Convention

Feature Module Path
auth features.auth
home features.home
divination features.divination
settings features.settings

Examples:

// features/auth/presentation/bloc/auth_bloc.dart
final Logger _logger = getLogger('features.auth.bloc');

// features/home/data/repositories/home_repository.dart
final Logger _logger = getLogger('features.home.repository');

// core/network/http_client.dart
final Logger _logger = getLogger('core.network.http_client');

Log Levels

Level When to Use Noise Level Required
error All exceptions and failures Required Never skip
warning Degraded behavior, retry, fallback Minimal Only when action taken
info Key business events Minimal Only milestones
debug Detailed flow tracing (dev only) High Avoid in release

Error Logging Requirements

Every try-catch MUST log the exception:

try {
  await _repository.someOperation();
} catch (e, stackTrace) {
  _logger.error(
    message: 'Operation failed: $operationName',
    error: e,
    stackTrace: stackTrace,
    extra: {'context': 'relevant_data'},
  );
  // handle error
}

Error Logging Pattern

// features/auth/presentation/bloc/auth_bloc.dart
Future<void> start() async {
  try {
    final user = await _repository.recoverSession();
    // ...
  } catch (error, stackTrace) {
    _logger.error(
      message: 'Session recovery failed: ${error.runtimeType}',
      error: error.runtimeType.toString(),
      stackTrace: stackTrace,
    );
    await _repository.clearLocalSession();
    _state = AuthState(
      status: AuthStatus.unauthenticated,
      errorMessage: _toSafeMessage(error),
    );
    notifyListeners();
  }
}

Info Logging Requirements

Only log these milestone events:

  • User login/logout
  • Message sent/received
  • Data sync completed
  • Important state transitions

Info Logging Pattern

// Login success
_logger.info(
  message: 'User logged in',
  extra: {'user_id': user.id},
);

// Run success
_logger.info(
  message: 'Run completed',
  extra: {
    'run_id': runId,
    'thread_id': threadId,
    'duration_ms': duration.inMilliseconds,
  },
);

DO NOT log for every operation:

// WRONG: Logging every keystroke
onChanged: (value) {
  _logger.info('Input changed: $value'); // Too noisy
}

Warning Logging Requirements

Only log when taking corrective action:

  • Retrying after failure
  • Using fallback data
  • Skipping malformed data
  • Deprecation warnings

Warning Logging Pattern

// Cache miss with fallback
_logger.warning(
  message: 'Cache miss, loading from remote',
  extra: {'key': cacheKey},
);

// Retry attempt
_logger.warning(
  message: 'Retry attempt',
  extra: {'attempt': 2, 'max_attempts': 3},
);

// Fallback data
_logger.warning(
  message: 'Using fallback data due to network timeout',
  extra: {'timeout_ms': 5000},
);

Debug Logging

Use sparingly, only in debug builds:

if (kDebugMode) {
  _logger.debug(
    message: 'Variable value',
    extra: {'variable': expensiveObject.toString()},
  );
}

Note: Debug logs are automatically filtered in release builds.


Prohibited Practices

Never log sensitive data

// WRONG: Logging password
_logger.info(message: 'User login', extra: {'password': password});

// WRONG: Logging token
_logger.debug(message: 'API call', extra: {'token': accessToken});

// WRONG: Logging PII
_logger.info(message: 'User profile', extra: {'email': userEmail});

Never log at debug level in production

// WRONG: Will log in release build
_logger.debug(message: 'Debug info: $sensitiveData');

Right way: Use kDebugMode guard:

if (kDebugMode) {
  _logger.debug(message: 'Variable value', extra: {'var': value});
}

Never skip error logging

// WRONG: Exception is caught but not logged
try {
  await operation();
} catch (e) {
  state = ErrorState();
  // Missing error log!
}

Never log in every iteration

// WRONG: Log every iteration
for (item in items) {
  _logger.debug('Processing item: ${item.id}'); // Too noisy
  process(item);
}

Right: Log only failures:

for (item in items) {
  try {
    process(item);
  } catch (e, stackTrace) {
    _logger.error(
      message: 'Item processing failed',
      error: e,
      stackTrace: stackTrace,
      extra: {'item_id': item.id},
    );
  }
}

Logger Implementation

Core Logger Class

// core/logging/logger.dart
import 'log_entry.dart';
import 'log_service.dart';

class Logger {
  final String module;
  final LogService? _service;
  final bool _isNoOp;

  Logger(this.module, this._service) : _isNoOp = _service == null;

  factory Logger.get(String module) {
    return Logger(module, _globalLogService);
  }

  void error({
    required String message,
    required Object error,
    required StackTrace stackTrace,
    Map<String, dynamic>? extra,
  }) {
    if (_isNoOp) {
      debugPrint(LogEntry(
        message: message,
        module: module,
        errorType: error.runtimeType.toString(),
      ).toConsoleString());
      return;
    }
    _service!.error(
      message: message,
      error: error,
      stackTrace: stackTrace,
      module: module,
      extra: extra ?? {},
    );
  }

  void info({
    required String message,
    Map<String, dynamic>? extra,
  }) {
    if (_isNoOp) return;
    _service!.info(
      message: message,
      module: module,
      extra: extra ?? {},
    );
  }

  void warning({
    required String message,
    Map<String, dynamic>? extra,
  }) {
    if (_isNoOp) return;
    _service!.warning(
      message: message,
      module: module,
      extra: extra ?? {},
    );
  }

  void debug({
    required String message,
    Map<String, dynamic>? extra,
  }) {
    if (_isNoOp || !kDebugMode) return;
    _service!.debug(
      message: message,
      module: module,
      extra: extra ?? {},
    );
  }
}

Logger getLogger(String module) => Logger.get(module);

Log Entry Structure

LogEntry Fields

// core/logging/log_entry.dart
class LogEntry {
  final DateTime timestamp;
  final LogLevel level;
  final String message;
  final String module;
  final String? errorType;
  final String? errorMessage;
  final String? stackTrace;
  final Map<String, dynamic>? extra;
}

enum LogLevel { debug, info, warning, error }

Log Format

{
  "timestamp": "2026-04-10T12:34:56.789Z",
  "level": "error",
  "module": "features.auth.bloc",
  "message": "Session recovery failed: SocketException",
  "error_type": "SocketException",
  "error_message": "Connection refused",
  "stack_trace": "...",
  "extra": {
    "user_id": "123e4567-e89b-12d3-a456-426614174000"
  }
}

Common Mistakes

Using print() instead of Logger

// WRONG: Never use print in production code
print('User logged in: $userId');

Right: Use Logger:

_logger.info(message: 'User logged in', extra: {'user_id': userId});

Logging sensitive data

// WRONG: Logging PII
_logger.info(message: 'User profile', extra: {
  'email': userEmail,
  'phone': userPhone,
});

Right: Exclude sensitive fields:

_logger.info(message: 'User profile loaded', extra: {'user_id': userId});

Catching without logging

// WRONG: Silent failure
try {
  await service.doSomething();
} catch (e) {
  // No logging, no re-raise
  return null;
}

Right: Log and re-raise:

try {
  await service.doSomething();
} catch (e, stackTrace) {
  _logger.error(
    message: 'Operation failed',
    error: e,
    stackTrace: stackTrace,
  );
  rethrow;
}

Examples from Codebase

AuthBloc Error Handling

// features/auth/presentation/bloc/auth_bloc.dart
class AuthBloc extends ChangeNotifier {
  final Logger _logger = getLogger('features.auth.bloc');

  Future<void> start() async {
    try {
      final user = await _repository.recoverSession();
      if (user == null) {
        _state = const AuthState(status: AuthStatus.unauthenticated);
      } else {
        _state = AuthState(status: AuthStatus.authenticated, user: user);
      }
      notifyListeners();
    } catch (error, stackTrace) {
      _logger.error(
        message: 'Session recovery failed: ${error.runtimeType}',
        error: error.runtimeType.toString(),
        stackTrace: stackTrace,
      );
      await _repository.clearLocalSession();
      _state = AuthState(
        status: AuthStatus.unauthenticated,
        errorMessage: _toSafeMessage(error),
      );
      notifyListeners();
    }
  }

  Future<void> logout() async {
    _logger.info(message: 'User logged out');
    _state = const AuthState(status: AuthStatus.unauthenticated);
    notifyListeners();

    unawaited(
      _repository.logout().catchError((Object error, StackTrace stackTrace) {
        _logger.error(
          message: 'User logout failed: ${error.runtimeType}',
          error: error.runtimeType.toString(),
          stackTrace: stackTrace,
        );
      }),
    );
  }
}

Network Error Handling

// core/network/http_client.dart
final Logger _logger = getLogger('core.network.http_client');

Future<Response> get(String path) async {
  try {
    final response = await _innerClient.get(path);
    final problem = ApiProblemMapper.tryParse(response);
    
    if (problem != null && response.statusCode != 401) {
      _logger.warning(
        message: 'HTTP error response',
        extra: {
          'status': response.statusCode,
          'path': path,
          'code': problem.code,
        },
      );
    }
    
    return response;
  } on SocketException catch (e, stackTrace) {
    _logger.error(
      message: 'Network error',
      error: e,
      stackTrace: stackTrace,
      extra: {'path': path},
    );
    throw ApiProblem(
      status: 0,
      title: 'Network Error',
      detail: 'No internet connection',
    );
  }
}