Perché il print() non basta

Quasi ogni progetto Flutter inizia con qualche print() sparso nel codice. Funziona finché l'app gira sul tuo emulatore, ma smette di essere utile nel momento esatto in cui serve davvero: quando un utente segnala un bug che tu non riesci a riprodurre.

I problemi concreti di print() sono tre:

  • Finisce in produzione. Le chiamate a print() non vengono rimosse dalla build di release: restano nei log di sistema, dove chiunque con un cavo USB e adb logcat può leggerle. Se stai stampando token, email o payload di risposta, hai appena creato una falla di sicurezza.
  • Viene troncato. Su Android il canale di log tronca le righe lunghe: stampare un JSON di 5 KB produce output incompleto. debugPrint mitiga il problema accodando le righe, ma non risolve nulla sul piano strutturale.
  • Non ha livelli né contesto. Non puoi filtrare, non sai da quale modulo arriva il messaggio, non hai timestamp affidabili e non puoi inoltrare nulla a un sistema di crash reporting.

Un sistema di logging serio in Flutter si costruisce su quattro pilastri: livelli, contesto, destinazioni (output) e redazione dei dati sensibili. Vediamoli uno a uno.

dart:developer: la base della piattaforma

Prima di installare qualsiasi pacchetto vale la pena conoscere dart:developer, incluso nell'SDK. La funzione log() scrive sul service protocol di Dart, quindi i messaggi compaiono in modo strutturato nel pannello Logging di DevTools, con nome, livello, errore e stack trace separati.

import 'dart:developer' as developer;

developer.log(
  'Login completato',
  name: 'auth',
  level: 800, // INFO, secondo la scala di package:logging
  time: DateTime.now(),
);

try {
  await repository.fetchProfile();
} catch (error, stackTrace) {
  developer.log(
    'Fetch profilo fallito',
    name: 'profile',
    level: 1000, // SEVERE
    error: error,
    stackTrace: stackTrace,
  );
}

I livelli seguono la convenzione di package:logging: FINE 500, INFO 800, WARNING 900, SEVERE 1000. Il vantaggio rispetto a print() è che DevTools può filtrare per name e livello; lo svantaggio è che non c'è formattazione, non c'è filtro automatico in release e non puoi inoltrare i messaggi altrove senza scriverti l'infrastruttura.

Il package logger: formattazione e livelli pronti all'uso

logger è la scelta più diffusa per lo sviluppo quotidiano. Offre livelli, printer configurabili, filtri e output sostituibili.

dependencies:
  logger: ^2.4.0
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';

final logger = Logger(
  level: kReleaseMode ? Level.warning : Level.debug,
  filter: ProductionFilter(),
  printer: PrettyPrinter(
    methodCount: 0,        // niente stack trace per i log normali
    errorMethodCount: 8,   // stack completo quando c'è un errore
    colors: !kReleaseMode,
    printEmojis: !kReleaseMode,
    dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
  ),
);

Due dettagli importanti:

  • Il filtro di default (DevelopmentFilter) silenzia tutto in release. Se vuoi che warning ed errori continuino a essere processati — per inoltrarli a un servizio remoto — devi usare ProductionFilter, che rispetta solo la soglia level.
  • Dalla versione 2.x il parametro printTime è stato sostituito da dateTimeFormat: se segui tutorial vecchi otterrai un errore di compilazione.

L'uso è immediato:

logger.t('Trace: costruzione widget');
logger.d('Carico ${items.length} elementi dalla cache');
logger.i('Sincronizzazione completata');
logger.w('Risposta API lenta: ${elapsed.inMilliseconds} ms');
logger.e('Checkout fallito', error: error, stackTrace: stackTrace);

Incapsula il logger dietro una facciata

Chiamare logger direttamente da ogni file lega tutto il progetto a una libreria specifica. Meglio definire un'interfaccia tua: potrai cambiare implementazione, sostituire il logger nei test o aggiungere destinazioni senza toccare centinaia di import.

abstract interface class AppLogger {
  void debug(String message, {Map<String, Object?> context});
  void info(String message, {Map<String, Object?> context});
  void warning(String message, {Object? error, StackTrace? stackTrace});
  void error(String message, {Object? error, StackTrace? stackTrace});
}

class LoggerAdapter implements AppLogger {
  LoggerAdapter(this._logger, {required this.scope});

  final Logger _logger;
  final String scope;

  String _format(String message, Map<String, Object?> context) {
    if (context.isEmpty) return '[$scope] $message';
    final pairs = context.entries.map((e) => '${e.key}=${e.value}').join(' ');
    return '[$scope] $message | $pairs';
  }

  @override
  void debug(String message, {Map<String, Object?> context = const {}}) =>
      _logger.d(_format(message, context));

  @override
  void info(String message, {Map<String, Object?> context = const {}}) =>
      _logger.i(_format(message, context));

  @override
  void warning(String message, {Object? error, StackTrace? stackTrace}) =>
      _logger.w('[$scope] $message', error: error, stackTrace: stackTrace);

  @override
  void error(String message, {Object? error, StackTrace? stackTrace}) =>
      _logger.e('[$scope] $message', error: error, stackTrace: stackTrace);
}

Il campo scope è il contesto minimo indispensabile: auth, sync, checkout. Quando apri DevTools con 400 righe di log, poter cercare [sync] fa la differenza tra cinque minuti e un'ora di debug.

Log strutturati: pensare in chiave/valore, non in frasi

Una riga come Errore durante il salvataggio è inutile. Una riga come:

[sync] upload fallito | entity=order id=8821 attempt=3 status=503 elapsedMs=1840

ti dice cosa è successo, a quale risorsa, dopo quanti tentativi e quanto è durato. La regola pratica è: il messaggio descrive l'evento, il contesto porta i dati. Non concatenare valori nel testo in modo libero, passali come mappa.

In release, se inoltri i log a un backend, conviene serializzarli in JSON:

class JsonPrinter extends LogPrinter {
  @override
  List<String> log(LogEvent event) {
    final payload = <String, Object?>{
      'ts': DateTime.now().toUtc().toIso8601String(),
      'level': event.level.name,
      'msg': event.message.toString(),
      if (event.error != null) 'error': event.error.toString(),
    };
    return [jsonEncode(payload)];
  }
}

Redigere i dati sensibili

Questo è il punto che la maggior parte dei progetti sbaglia. Token, password, numeri di carta, codici fiscali e indirizzi email non devono mai finire nei log, nemmeno in debug: le build di debug circolano fra colleghi, tester e macchine CI.

Un piccolo helper di sanificazione risolve il 90% dei casi:

const _sensitiveKeys = {
  'password', 'token', 'access_token', 'refresh_token',
  'authorization', 'pin', 'iban', 'card_number',
};

Map<String, Object?> redact(Map<String, Object?> input) {
  return input.map((key, value) {
    if (_sensitiveKeys.contains(key.toLowerCase())) {
      return MapEntry(key, '***');
    }
    if (value is Map<String, Object?>) return MapEntry(key, redact(value));
    return MapEntry(key, value);
  });
}

String maskEmail(String email) {
  final parts = email.split('@');
  if (parts.length != 2 || parts.first.isEmpty) return '***';
  return '${parts.first[0]}***@${parts.last}';
}

E applicalo nell'interceptor HTTP, dove il rischio è più alto:

class LoggingInterceptor extends Interceptor {
  LoggingInterceptor(this._log);
  final AppLogger _log;

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    _log.debug('HTTP →', context: {
      'method': options.method,
      'path': options.path,
      'headers': redact(Map<String, Object?>.from(options.headers)),
    });
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    _log.debug('HTTP ←', context: {
      'path': response.requestOptions.path,
      'status': response.statusCode,
      'bytes': response.data is String ? (response.data as String).length : null,
    });
    handler.next(response);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    _log.warning(
      'HTTP errore ${err.requestOptions.path}',
      error: err,
    );
    handler.next(err);
  }
}

Nota che il body della risposta non viene loggato per intero: ne registriamo solo la dimensione. Loggare interi payload è il modo più rapido per riempire i log di dati personali.

Inoltrare i log al crash reporting

I log diventano davvero preziosi quando accompagnano un crash. Sia Crashlytics sia Sentry supportano il concetto di breadcrumb: una coda di eventi recenti allegata automaticamente al report.

Con logger basta scrivere un LogOutput personalizzato e comporlo con quello di console:

class CrashlyticsOutput extends LogOutput {
  @override
  void output(OutputEvent event) {
    final crashlytics = FirebaseCrashlytics.instance;
    for (final line in event.lines) {
      crashlytics.log(line); // breadcrumb
    }
    if (event.level.index >= Level.error.index) {
      crashlytics.recordError(
        event.origin.error ?? event.origin.message,
        event.origin.stackTrace,
        fatal: false,
      );
    }
  }
}

final logger = Logger(
  filter: ProductionFilter(),
  printer: kReleaseMode ? JsonPrinter() : PrettyPrinter(methodCount: 0),
  output: MultiOutput([
    ConsoleOutput(),
    if (kReleaseMode) CrashlyticsOutput(),
  ]),
);

Con Sentry l'equivalente è Sentry.addBreadcrumb(Breadcrumb(message: line, level: ...)).

Catturare tutto: errori Flutter, errori async e isolate

Un sistema di logging incompleto perde proprio gli errori più importanti. Servono tre agganci distinti:

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // 1. Errori nel framework (build, layout, paint)
  FlutterError.onError = (details) {
    logger.e('FlutterError', error: details.exception, stackTrace: details.stack);
    FlutterError.presentError(details);
  };

  // 2. Errori asincroni non gestiti nell'isolate principale
  PlatformDispatcher.instance.onError = (error, stack) {
    logger.e('Uncaught async error', error: error, stackTrace: stack);
    return true; // errore gestito
  };

  runApp(const MyApp());
}

PlatformDispatcher.instance.onError (disponibile da Flutter 3.3) ha sostituito nella pratica il vecchio pattern con runZonedGuarded: è più semplice e non interferisce con il binding. Se usi Isolate.spawn ricorda che gli isolate secondari hanno una gestione errori separata: registra un ReceivePort con Isolate.current.addErrorListener dentro l'isolate oppure passa onError allo spawn.

Scrivere i log su file (e ruotarli)

Per le app che devono funzionare offline o in contesti industriali, un file locale consente all'utente di esportare la diagnostica. Il punto critico è la rotazione: senza limiti, un log su file riempie lo storage del dispositivo.

class RotatingFileOutput extends LogOutput {
  RotatingFileOutput(this.file, {this.maxBytes = 512 * 1024});

  final File file;
  final int maxBytes;
  IOSink? _sink;

  @override
  Future<void> init() async {
    if (await file.exists() && await file.length() > maxBytes) {
      final backup = File('${file.path}.1');
      if (await backup.exists()) await backup.delete();
      await file.rename(backup.path);
    }
    _sink = file.openWrite(mode: FileMode.append);
  }

  @override
  void output(OutputEvent event) {
    for (final line in event.lines) {
      _sink?.writeln(line);
    }
  }

  @override
  Future<void> destroy() async {
    await _sink?.flush();
    await _sink?.close();
  }
}

Il file va creato in getApplicationSupportDirectory() (non in getApplicationDocumentsDirectory(), che su iOS può finire nel backup iCloud e nell'app Files) e l'esportazione si gestisce con share_plus.

Testare il logging

Avendo una facciata AppLogger, nei test puoi iniettare un'implementazione che accumula i messaggi in memoria e verificare che gli eventi critici vengano registrati:

class InMemoryLogger implements AppLogger {
  final List<String> messages = [];
  @override
  void error(String message, {Object? error, StackTrace? stackTrace}) =>
      messages.add('ERROR $message');
  // ... altri metodi
}

test('registra un errore quando il salvataggio fallisce', () async {
  final log = InMemoryLogger();
  final repo = OrderRepository(api: FailingApi(), logger: log);

  await repo.save(order);

  expect(log.messages, contains(startsWith('ERROR')));
});

Checklist finale

  • Vieta print() nel progetto: aggiungi avoid_print: error in analysis_options.yaml.
  • Usa livelli coerenti: debug per lo sviluppo, info per gli eventi di business, warning per le anomalie recuperabili, error solo per ciò che richiede un intervento.
  • Dai sempre uno scope ai log e passa i dati come coppie chiave/valore.
  • Redigi header, token e dati personali prima di scrivere la riga, non dopo.
  • In release alza la soglia ad almeno warning e inoltra tutto a Crashlytics o Sentry.
  • Non loggare dentro build() o in callback ad alta frequenza come onScroll: la serializzazione delle stringhe costa e introduce jank.
  • Se scrivi su file, imposta un limite di dimensione e una rotazione.

Un logging ben progettato non si nota finché non serve. Quando serve, è la differenza tra riprodurre un bug in dieci minuti e chiudere il ticket con un "non riproducibile".