Skip to main content

Command Palette

Search for a command to run...

Enhance Your Flutter App with a Real Logger Instead of print()

Updated
6 min readView as Markdown
Enhance Your Flutter App with a Real Logger Instead of print()
G
Hello, I am a senior Flutter developer with vast experience in crafting mobile applications. I am a seasoned community organizer with vast experience in launching and building Google Developer communities under GDG Bugiri Uganda and Flutter Kampala.

When you're starting out in Flutter, it feels natural to sprinkle print() statements everywhere. It's quick, it's always available, and it gets the job done for a small project. As your app grows, that habit stops being harmless and starts being a liability — not because print() is broken, but because it was never built for what you're actually asking it to do.

The problem with print()

Four issues compound as an app grows past the size where you can just read the console yourself.

No log levels. Every print() message looks identical. An error, a warning, and a stray debug note all land in the console with the same weight, which makes filtering out noise during a real investigation harder than it needs to be.

No persistence. Once the app session ends, every print() statement is gone. If a user reports a crash three screens deep into a flow you can't reproduce, you have nothing — there's no way to retrieve what actually happened on their device.

Real performance cost at volume. A handful of print() calls cost nothing. Hundreds of them in a hot path — a list rebuilding on scroll, a network layer logging every request — add measurable overhead, because print() was designed for occasional debugging output, not structured, high-frequency logging.

No metadata. print() captures none of a timestamp, the file it came from, or the line number. Staring at a stack of identical-looking debug lines while trying to reconstruct the order events happened in is a specific, avoidable kind of pain.

The case for a real logger

A proper logging package — logger is a common, solid choice — solves all four problems directly.

Log levels give you filtering for free.

final logger = Logger();
logger.i('App started');
logger.w('Low memory warning');
logger.e('API request failed');

Logs can persist and travel. Most logging libraries support writing to a file or forwarding to a remote service, so you can gather real diagnostic data from users who hit bugs you can't reproduce locally, instead of asking them to describe what happened from memory.

Output is structured and readable — timestamps, colored levels, pretty-printed JSON, and stack traces attached automatically, instead of a wall of undifferentiated text.

Production behavior is configurable, which is the feature that actually matters once you ship: you decide what gets logged in debug builds versus what (if anything) survives into a release build.

A complete example

import 'package:logger/logger.dart';

var logger = Logger();

void main() {
  logger.d('This is a debug message');
  logger.i('This is an info message');
  logger.w('This is a warning');
  logger.e('This is an error with stacktrace', error: Exception('Oops'), stackTrace: StackTrace.current);
}

Run this and you get colorized, leveled output in place of raw print() text — visually distinct enough that scanning a long session for the one .e() call among a hundred .i() calls is immediate rather than a search.

Gating log level by build mode

The claim that a logger lets you "log everything in debug, only errors in production" is only useful if you can see how — and the mechanism is Flutter's own kReleaseMode constant, checked once at logger construction:

import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';

final logger = Logger(
  level: kReleaseMode ? Level.warning : Level.debug,
  printer: kReleaseMode ? SimplePrinter() : PrettyPrinter(),
);

In a debug build, every call from .d() up through .e() prints with full formatting. In a release build, .d() and .i() calls are silently dropped — they still exist in your code as documentation of what's happening at each step, but they cost nothing and reveal nothing to a user with adb logcat open, while warnings and errors still surface where you actually need them: in whatever you've wired logging to report back to.

Forwarding errors to a crash reporter

Local, in-console logging solves debugging on your own device. It does nothing for the user who hits a bug you'll never see unless you connect the logger to a service that persists across sessions and devices. The logger package's Output interface exists exactly for this — it lets you intercept log events and forward them anywhere:

class CrashlyticsOutput extends LogOutput {
  @override
  void output(OutputEvent event) {
    if (event.level.index >= Level.warning.index) {
      final message = event.lines.join('\n');
      FirebaseCrashlytics.instance.log(message);
      if (event.level == Level.error) {
        FirebaseCrashlytics.instance.recordError(
          event.origin.error ?? message,
          event.origin.stackTrace,
          fatal: false,
        );
      }
    }
  }
}

final logger = Logger(
  level: kReleaseMode ? Level.warning : Level.debug,
  output: MultiOutput([ConsoleOutput(), CrashlyticsOutput()]),
);

Now a single logger.e('Payment failed', error: e, stackTrace: st) call does three things at once: prints locally while you're developing, records a breadcrumb in Crashlytics either way, and creates a non-fatal error report you'll see in the Crashlytics dashboard the next morning — without ever asking a user what they were doing when it happened. Sentry's Dart SDK offers an equivalent integration built on the logging package if that's your crash-reporting tool of choice instead.

Best practices

Reserve .e() for genuine failures — marking routine, expected conditions as errors trains you to ignore your own error log, which defeats the purpose of having levels at all. Never log passwords, tokens, or personally identifying user data; a crash report that leaks a session token is a worse outcome than the crash itself. Keep production log volume deliberately minimal, both for performance and because a wide-open firehose to a remote service has its own cost and its own privacy surface. And treat the crash-reporting pairing as the point, not an optional extra — a logger that never leaves the device only helps you, and the goal is closing the loop on bugs you'll never personally see.

A brief word on alternatives

logger isn't the only reasonable option. Flutter's own debugPrint throttles output to avoid Android's log-line truncation and is a fine lightweight step up from raw print() if you don't need levels or persistence yet. The Dart team's own logging package is a leaner, more standard-library-adjacent choice, and it's what Sentry's Dart integration builds on directly. Choose based on what you're pairing it with — if you're already committed to Crashlytics or Sentry, let that answer the question rather than picking a logging package first and working backward.

Final thoughts

print() feels harmless in a small project because the project is small enough that you're the only one who ever reads the output, and the session ends before it matters that nothing persisted. Neither of those conditions holds once real users are running your app on devices you'll never see. A real logger costs one dependency and a few minutes of setup, and it's what turns "a user said the app crashed" into an actual stack trace on your screen the next morning.


References

  1. logger package on pub.dev

  2. Firebase Crashlytics for Flutter

  3. Sentry Dart SDK — logging integration

  4. Dart logging package

  5. Flutter kReleaseMode and build modes