Many Flutter developer has done this:
try {
final movies = await api.getMovies();
} catch (e) {
print('Error: $e'); // or debugPrint(...)
}It works during development. You see the error in the console, fix it, move on.
Then you ship the app. A user reports a blank screen. You open your dashboard and see… nothing. Because print() doesn't exist in release builds.
In this article, I'll show you a simple logging setup that solves three problems:
- During development → beautiful, readable error logs in your console
- In production → errors automatically sent to Crashlytics (or any crash reporting tool)
- For your users → friendly error messages, never raw exception dumps

If you are a member, please continue, otherwise, read the full story here.
It's three small files. Let's build them.
Step 1: Define How Your App Can Fail
Before we can log errors properly, we need to describe them properly. Right now, when something goes wrong, you get a raw Exception or a string — neither tells you much.
Let's create a Failure class that carries everything we need:
sealed class Failure {
final String message; // What the USER sees ("Check your connection")
final String? debugMessage; // What YOU see ("GET /movies → SocketException")
final int? statusCode; // HTTP status code, if applicable
final StackTrace? stackTrace;
const Failure(
this.message, {
this.statusCode,
this.debugMessage,
this.stackTrace,
});
}The key insight: two separate messages.
| Field | Who sees it | Example |
|---------------|---------------------------------|--------------------------------------------------------------|
| message | The user, on screen | "Check your internet connection" |
| debugMessage | You, in logs / Crashlytics | "GET /api/movies — Connection refused (port 443)" |Now, create subtypes for each kind of failure your app can encounter:
class NetworkFailure extends Failure {
const NetworkFailure([super.message = 'Check your internet connection']);
NetworkFailure.withDebug({
String message = 'Check your internet connection',
String? debugMessage,
StackTrace? stackTrace,
}) : super(message, debugMessage: debugMessage, stackTrace: stackTrace);
}
class ServerFailure extends Failure {
const ServerFailure([super.message = 'Server error, try again later']);
ServerFailure.withDebug({
String message = 'Server error, try again later',
int? statusCode,
String? debugMessage,
StackTrace? stackTrace,
}) : super(message, statusCode: statusCode,
debugMessage: debugMessage, stackTrace: stackTrace);
}
class UnauthorizedFailure extends Failure {
const UnauthorizedFailure([super.message = 'Session expired']);
// ... same pattern with .withDebug()
}
class NotFoundFailure extends Failure {
const NotFoundFailure([super.message = 'Content not found']);
// ... same pattern
}
class UnexpectedFailure extends Failure {
const UnexpectedFailure([super.message = 'Unexpected error occurred']);
// ... same pattern
}Each type has two constructors:
- Simple:
const NetworkFailure()— when you just need a quick, clean error .withDebug()— when you have the raw exception details to attach
Why
sealed? It tells the Dart compiler: "these are ALL the failure types that exist." If you everswitchover aFailure, the compiler warns you if you miss a case.
You can customize this for your app
I'm building a movie app, so these six types cover my cases. Your app might need different ones — PaymentFailure, AuthFailure, ValidationFailure, whatever makes sense. The structure stays the same.
Step 2: Build the Logger
Here's the entire AppLogger — it's surprisingly small:
import 'package:flutter/foundation.dart';
class AppLogger {
AppLogger._(); // Can't be instantiated — all methods are static
/// Log a failure — pretty console in dev, Crashlytics in release
static void failure(Failure f, {String? context}) {
if (kDebugMode) {
debugPrint('');
debugPrint('━━━ FAILURE ━━━━━━━━━━━━━━━━━━━━━━━━');
debugPrint('Type : ${f.runtimeType}');
debugPrint('Message : ${f.message}');
if (context != null) debugPrint('Context : $context');
if (f.statusCode != null) debugPrint('Status : ${f.statusCode}');
if (f.debugMessage != null) debugPrint('Debug : ${f.debugMessage}');
if (f.stackTrace != null) {
debugPrint('Stack :');
debugPrint('${f.stackTrace}');
}
debugPrint('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
} else {
// Release mode → send to Crashlytics or others
// FirebaseCrashlytics.instance.recordError(
// f,
// f.stackTrace ?? StackTrace.current,
// reason: f.debugMessage ?? f.message,
// );
}
}
/// Info — debug only, completely stripped in release
static void info(String message) {
if (kDebugMode) {
debugPrint('[INFO] $message');
}
}
/// Warning — something unexpected but not fatal
static void warning(String message) {
if (kDebugMode) {
debugPrint('[WARN] $message');
}
// Optionally send as non-fatal to Crashlytics in release
}
}That's it. Let's break down why it's designed this way.
Why kDebugMode and not a custom flag?
kDebugMode is a compile-time constant from Flutter's foundation.dart. This is important because the Dart compiler doesn't just skip the debug branch in release — it removes it entirely from the binary. Your pretty-print code doesn't add a single byte to your production app.
Why debugPrint and not print?
debugPrint throttles output so long messages don't get truncated by the system. print can silently cut off long stack traces. Small detail, big difference when debugging.
Why a private constructor?
AppLogger._() prevents anyone from writing final logger = AppLogger(). All methods are static — you just call AppLogger.failure(f) anywhere. No setup, no injection, no boilerplate.
Three severity levels
Think of these as lanes on a highway:
| Method | When to use it | Dev Console Output | Production Behavior |
|----------------|-----------------------------------------------------|--------------------|---------------------|
| **info()** | Normal events worth tracking | `[INFO] ...` | Removed (zero cost) |
| **warning()** | Unexpected but non-breaking issues | `[WARN] ...` | Optional → non-fatal logging |
| **failure()** | Errors that impact user experience | Visual debug card | Sent to Crashlytics (`recordError`) |Step 3: Use It — Practical Examples
Example 1: Logging an API Error
Let's say you're fetching movies from an API. When it fails, you want to log the failure and show the user a friendly message.
Without AppLogger:
Future<List<Movie>> getMovies() async {
try {
final response = await dio.get('/movies');
return parseMovies(response.data);
} catch (e) {
print('Error fetching movies: $e'); // 🫠 Gone in release
rethrow;
}
}With AppLogger:
Future<List<Movie>> getMovies() async {
try {
final response = await dio.get('/movies');
return parseMovies(response.data);
} on DioException catch (e, st) {
// Known error — network/server issue
final failure = ServerFailure.withDebug(
statusCode: e.response?.statusCode,
debugMessage: '${e.requestOptions.uri} — ${e.message}',
stackTrace: st,
);
AppLogger.failure(failure, context: 'getMovies()');
throw failure;
} catch (e, st) {
// Unexpected error — parsing bug, null access, anything we didn't predict
final failure = UnexpectedFailure.withDebug(
debugMessage: e.toString(),
stackTrace: st,
);
AppLogger.failure(failure, context: 'getMovies()');
throw failure;
}
}When this fails during development, your console shows:
━━━ FAILURE ━━━━━━━━━━━━━━━━━━━━━━━━
Type : ServerFailure
Message : Server error, try again later
Context : getMovies()
Status : 500
Debug : https://api.example.com/movies → Connection refused
Stack :
#0 ApiClient.get (api_client.dart:56)
#1 MovieService.getMovies (movie_service.dart:12)
...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━At a glance you know: what failed (ServerFailure), where (getMovies), why (500 + connection refused), and how to trace it (stack trace). Compare that to Error fetching movies: DioException [unknown]...
And in release? The exact same information goes to Crashlytics automatically. You don't change a single line of code.
Example 2: Logging Cache Issues as Warnings
Sometimes things go wrong but the user doesn't need to know. A local cache being corrupted is a good example — you'll just re-fetch from the network:
Movie? getCachedMovie(int id) {
try {
final json = cacheBox.get('movie_$id');
if (json == null) return null;
return Movie.fromJson(jsonDecode(json));
} catch (e) {
AppLogger.warning('Corrupted cache for movie_$id: $e');
cacheBox.delete('movie_$id'); // Clean up the bad data
return null;
}
}The user never sees this. But you'll see [WARN] Corrupted cache for movie_42: FormatException... in your console, which tells you something is writing bad data to the cache.
Example 3: Logging Image Load Failures
Broken images are common — CDNs go down, URLs expire. You don't want to crash, but you do want to know:
CachedNetworkImage(
imageUrl: posterUrl,
errorWidget: (context, url, error) {
AppLogger.warning('Failed to load image: $url\nError: $error');
return Icon(Icons.broken_image);
},
)Example 4: Info Logs for Tracking Flow
During development, it helps to trace what's happening:
AppLogger.info('User navigated to MovieDetails(id: $movieId)');
AppLogger.info('Cache hit for popular movies page 1');
AppLogger.info('Token refreshed successfully');These are stripped from release builds entirely, zero performance cost.
Bonus: Pairing with a Result Type
If you want to take this one step further, you can pair the Failure class with a Result type to eliminate try/catch from your business logic entirely:
sealed class Result<T> {
const Result();
R when<R>({
required R Function(T data) success,
required R Function(Failure failure) failure,
});
}
class Success<T> extends Result<T> {
final T data;
const Success(this.data);
@override
R when<R>({
required R Function(T data) success,
required R Function(Failure failure) failure,
}) => success(data);
}
class Err<T> extends Result<T> {
final Failure failure;
const Err(this.failure);
@override
R when<R>({
required R Function(T data) success,
required R Function(Failure failure) failure,
}) => failure(this.failure);
}Now your methods return Result<T> instead of throwing:
Future<Result<List<Movie>>> getMovies() async {
try {
final response = await dio.get('/movies');
return Success(parseMovies(response.data));
} on DioException catch (e, st) {
final f = ServerFailure.withDebug(
statusCode: e.response?.statusCode,
debugMessage: '${e.requestOptions.uri} — ${e.message}',
stackTrace: st,
);
AppLogger.failure(f, context: 'getMovies()');
return Err(f);
}
}And the caller handles both paths explicitly:
final result = await getMovies();
result.when(
success: (movies) => showMovies(movies),
failure: (f) => showError(f.message), // 👈 user-safe message
);No try/catch at the call site. The compiler forces you to handle both cases. And because f.message is always user-friendly ("Server error, try again later"), you can show it directly in a snackbar or error widget.
This is optional.
AppLoggerworks perfectly fine with regulartry/catch. TheResulttype is just a nice upgrade when you're ready for it.
The Complete Setup
Three files, all in your project's core/ or utils/ directory:
lib/
└── core/
├── error/
│ ├── failures.dart // Failure + subtypes
│ └── result.dart // Result<T> (optional)
└── logging/
└── app_logger.dart // The loggerWhen You're Ready for Crashlytics
Uncomment the release branch in AppLogger.failure():
} else {
// Any other that you used
FirebaseCrashlytics.instance.recordError(
f,
f.stackTrace ?? StackTrace.current,
reason: f.debugMessage ?? f.message,
information: [
if (context != null) DiagnosticsNode.message('context: $context'),
DiagnosticsNode.message('type: ${f.runtimeType}'),
if (f.statusCode != null)
DiagnosticsNode.message('statusCode: ${f.statusCode}'),
],
);
}Because every Failure already carries debugMessage, stackTrace, and statusCode, Crashlytics gets rich, searchable reports. No refactoring needed.
Why This Works
Let me summarize the core ideas:
- Two messages, two audiences. Every
Failurehasmessage(for the user) anddebugMessage(for you). You never accidentally show a stack trace to a user, and you never lose debug info in production. - One logger, two destinations. In debug → formatted console output. In release → Crashlytics. The call site (
AppLogger.failure(f)) doesn't know or care which one runs. - Severity matters. Not every problem is equal. A failed API call is a
failure(). A broken cache is awarning(). A navigation event isinfo(). Each type gets the treatment it deserves. - Zero cost in production.
kDebugModeis a compile-time constant. The Dart compiler removes debug-only code entirely from release builds. Yourinfo()logs add zero overhead to your production app. - Built for growth. Start with console logging today. Add Crashlytics tomorrow. Add Sentry next month. The
Failureclasses andAppLoggercall sites never change — you only update the release branch inside the logger.
If this saved you some debugging pain, give it a 👏. I write about practical Flutter patterns that actually work in production.
Feedback, suggestions, or improvements are always appreciated.