Flutter Logging in Production: Moving Beyond debugPrint

Flutter

Flutter logging is easy to start (just call debugPrint()) and surprisingly hard to scale. In production, you need log levels, consistent formatting, privacy-safe data handling, and a way to connect logs to crashes and user sessions. This article shows a practical logging setup using a logger package, PII masking, and integration patterns for Sentry or Firebase Crashlytics—without turning your codebase into a mess.

Audience: IntermediateTested on: Flutter 3.x, Dart 3.x, Android 14 / iOS 17, macOS 14

Why debugPrint doesn’t scale

debugPrint() is fine for local debugging, but it breaks down quickly when:

  • You need log levels (debug/info/warn/error).
  • You want structured logs that can be searched and filtered.
  • You need to avoid leaking PII (emails, tokens, card numbers).
  • You want logs connected to crash reports (Sentry/Crashlytics).

The fix is to centralize logging behind an interface and make it configurable by environment (dev/stg/prod).

Recommended approach

  • Create a small AppLogger interface your app uses everywhere.
  • Implement it using a logger package (logger is a popular choice).
  • Add a PII scrubber that redacts sensitive values.
  • Forward error logs to Sentry or Crashlytics.
  • Control verbosity using flavors / dart-define (dev=debug, prod=warn+error).

Install a logger package

Use a dedicated package rather than printing raw strings.

# pubspec.yaml
dependencies:
  logger: ^latest

Then:

flutter pub get

Define a logger interface (so you can swap implementations)

// lib/logging/app_logger.dart
abstract class AppLogger {
  void debug(String message, {Map<String, Object?>? data});
  void info(String message, {Map<String, Object?>? data});
  void warn(String message, {Map<String, Object?>? data});
  void error(String message, Object error, StackTrace stackTrace,
      {Map<String, Object?>? data});
}

This avoids “logger package lock-in” across your whole codebase.

Add PII masking (redaction) before logs leave your app

Decide what your team treats as sensitive. Common PII/secrets include:

  • Email addresses
  • Access/refresh tokens
  • Authorization headers
  • Card numbers / personal IDs

Implement a simple redactor that sanitizes both messages and structured data maps.

// lib/logging/pii_redactor.dart
class PiiRedactor {
  static final _email = RegExp(r'[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}', caseSensitive: false);
  static final _bearer = RegExp(r'Bearer\s+[A-Za-z0-9._-]+', caseSensitive: false);

  String redactText(String input) {
    var s = input;
    s = s.replaceAll(_email, '[REDACTED_EMAIL]');
    s = s.replaceAll(_bearer, 'Bearer [REDACTED_TOKEN]');
    return s;
  }

  Map<String, Object?> redactMap(Map<String, Object?> data) {
    final out = <String, Object?>{};
    for (final entry in data.entries) {
      final key = entry.key.toLowerCase();
      final value = entry.value;

      if (key.contains('token') || key.contains('authorization') || key.contains('password')) {
        out[entry.key] = '[REDACTED]';
        continue;
      }

      if (value is String) {
        out[entry.key] = redactText(value);
      } else {
        out[entry.key] = value;
      }
    }
    return out;
  }
}

Implement AppLogger using the logger package

// lib/logging/logger_impl.dart
import 'package:logger/logger.dart';
import 'app_logger.dart';
import 'pii_redactor.dart';

enum AppLogLevel { debug, info, warn, error, off }

class LoggerImpl implements AppLogger {
  LoggerImpl({required AppLogLevel minLevel})
      : _minLevel = minLevel,
        _redactor = PiiRedactor(),
        _logger = Logger(
          printer: PrettyPrinter(
            methodCount: 0,
            errorMethodCount: 8,
            lineLength: 100,
            printEmojis: false,
          ),
        );

  final Logger _logger;
  final AppLogLevel _minLevel;
  final PiiRedactor _redactor;

  bool _enabled(AppLogLevel level) => level.index >= _minLevel.index;

  @override
  void debug(String message, {Map<String, Object?>? data}) {
    if (!_enabled(AppLogLevel.debug)) return;
    _logger.d(_format(message, data));
  }

  @override
  void info(String message, {Map<String, Object?>? data}) {
    if (!_enabled(AppLogLevel.info)) return;
    _logger.i(_format(message, data));
  }

  @override
  void warn(String message, {Map<String, Object?>? data}) {
    if (!_enabled(AppLogLevel.warn)) return;
    _logger.w(_format(message, data));
  }

  @override
  void error(String message, Object error, StackTrace stackTrace,
      {Map<String, Object?>? data}) {
    if (!_enabled(AppLogLevel.error)) return;
    _logger.e(_format(message, data), error: error, stackTrace: stackTrace);

    // Forward to crash tool here (see sections below).
    // _crashReporter.recordError(error, stackTrace, reason: message, data: data);
  }

  String _format(String message, Map<String, Object?>? data) {
    final safeMsg = _redactor.redactText(message);
    if (data == null || data.isEmpty) return safeMsg;
    final safeData = _redactor.redactMap(data);
    return '$safeMsg | data=$safeData';
  }
}

Control verbosity by environment

Use flavors / --dart-define to pick a minimum log level:

// lib/env/app_env.dart
class AppEnv {
  static const logLevel = String.fromEnvironment('LOG_LEVEL', defaultValue: 'info');
}

AppLogLevel parseLevel(String value) {
  switch (value.toLowerCase()) {
    case 'debug':
      return AppLogLevel.debug;
    case 'info':
      return AppLogLevel.info;
    case 'warn':
      return AppLogLevel.warn;
    case 'error':
      return AppLogLevel.error;
    case 'off':
      return AppLogLevel.off;
    default:
      return AppLogLevel.info;
  }
}

Example command:

flutter run --dart-define=LOG_LEVEL=debug

In production, you typically set warn or error only.

Integrating with Sentry

If you use Sentry, forward error logs to Sentry with extra context (but keep it redacted).

// Pseudocode pattern (keep your logger interface unchanged)
logger.error(
  'API request failed',
  error,
  stackTrace,
  data: {
    'endpoint': '/v1/profile',
    'status': 500,
    'requestId': requestId,
  },
);

At the forwarding layer, attach breadcrumbs for info/warn logs and send exceptions on error logs. Keep any token/header fields removed or redacted before sending.

Integrating with Firebase Crashlytics

Crashlytics is great for production crashes. A common pattern is:

  • Send non-fatal errors using recordError
  • Add custom keys (build flavor, env, userId hash)
  • Use breadcrumbs (small logs) sparingly

The same forwarding hook in LoggerImpl.error() is the best place to do it.

What to log (and what not to log)

  • Log: screen names, action names, request IDs, status codes, timing, retry counts.
  • Don’t log: passwords, full tokens, raw authorization headers, full card/ID numbers, user content unless you have strong consent and redaction.

Prefer structured data maps over long unstructured strings. It makes searching and correlation much easier.

Practical logging checklist

  • ✅ One AppLogger used everywhere
  • ✅ Log levels controlled by env
  • ✅ Redaction before printing or sending
  • ✅ Error logs forwarded to crash tool
  • ✅ Correlation ID (requestId / traceId) included
  • ✅ Minimal logs in production (warn/error)

Conclusion

Graduating from debugPrint is one of the easiest ways to make a Flutter app feel “production-grade.” With a small logging interface, redaction, and crash-report forwarding, you get searchable diagnostics without leaking sensitive data. Start small, centralize the code, and let environment config control how noisy your logs are.

Updated: 2025-11-12

Comment

Copied title and URL