flutter_bug_report 0.3.2
flutter_bug_report: ^0.3.2 copied to clipboard
Flutter bug reports with the log already attached. Captures logger output, debugPrint and crashes, redacts secrets, and builds a bounded txt/json/zip. No vendor, no SDK.
flutter_bug_report
Bug reports with the log already attached.
The payload a shake-to-report SDK sends — without the vendor.
Every bug report that arrives as "it didn't work" costs somebody an afternoon. Attaching Flutter crash logs to a report by hand costs them the rest of it. The fix isn't a better form — it's attaching the log, the build number and the phone to whatever the person typed. Tools that do this exist, and they're SDKs from companies that want your data on their servers.
flutter_bug_report is that attachment, and nothing else. It collects, and it
hands you a file. Where the file goes — Sentry, Crashlytics, Jira, Telegram,
your own endpoint — is your app's business. No client, no DSN, no signup.
await BugReport.init();
BugReport.info('opened the payment screen');
// …later, when someone reports something
final bundle = await BugReport.build(
description: 'Payment screen froze after I pressed pay',
metadata: {'app_version': '1.0.17+2185', 'platform': 'android'},
);
await myBackend.upload(bundle.bytes, bundle.fileName, bundle.mimeType);
No instance to hold, nothing to inject, no service locator. There's one log per app, the same way there's one console.
Already have a
BugReportof your own — an exception class, most often? Import around it:import 'package:flutter_bug_report/flutter_bug_report.dart' hide BugReport;and reach this one through a prefixed import instead.
What it looks like #
A sheet, a sentence, and the log goes with it — from Alif Business, in production.
What you get #
And this is what arrives. Note what happened to the bearer token and the card number on the way:
=== flutter_bug_report ===
generated_at: 2026-08-26T07:19:11.214967Z
description: The client list was empty after I pressed refresh
entry_count: 5
truncated: false
metadata:
app_version: 1.0.17+2185
platform: android
os_version: Android 14
device_model: samsung SM-A546E
==================
2026-08-26T07:19:11.201742Z INFO signed in
2026-08-26T07:19:11.209049Z INFO GET /clients
{"status":500,"authorization":"Bearer «redacted»","ms":1840}
2026-08-26T07:19:11.210375Z WARNING retrying in 2s
2026-08-26T07:19:11.210420Z INFO paid with card ************4242
2026-08-26T07:19:11.211374Z ERROR could not load clients
Bad state: clients came back null
#0 ClientsCubit.load (package:app/clients_cubit.dart:41:7)
<asynchronous suspension>
Everything else is optional #
The collector and the sheet are the whole package. What follows is off until you switch it on, and the ones that could carry somebody's details stay off until you have thought about it.
The route they took #
The most useful line in a bug report is often not an error — it is which screens they passed through to reach one.
MaterialApp(navigatorObservers: [BugReportObserver()]);
GoRouter(observers: [BugReportObserver()]);
route: push /clients ← /home
route: push /clients/details ← /clients
route: push /payment ← /clients/details
Route names only, never their arguments — an argument is where the client id and the phone number live.
A screenshot #
BugReportWrapper(withScreenshot: true, ...)
Off by default, and that is the right default. A screenshot carries whatever the screen carried, and unlike a log it cannot be redacted — nothing here can read what is in it. Switched on, the sheet shows the person the picture before it goes and one tap drops it. Nobody should find out afterwards what they sent.
It lands as screenshot.png inside the zip, captured before the sheet opens so
it shows the screen being reported rather than the form reporting it.
A log that survives the crash #
MemoryLogStore loses everything the process loses — including, at the worst
moment, the lines that explain why the process died.
await BugReport.init(store: FileLogStore(retention: Duration(days: 3)));
Opt in knowingly. A file on disk outlives the session, and a phone that is
shared, repaired or sold carries it along. Before switching it on: check your
redactors cover what your app logs, keep retention as short as you can stand,
and call BugReport.clear() on sign-out.
Who it happened to #
BugReport.identify(user.id); // and identify(null) on sign-out
An id and nothing else. A name, a phone number and an email are yours to send or
not, through metadata.
What the phone is #
Folded in automatically, from what Flutter itself knows:
platform · os_version · locale · screen · pixel_ratio · text_scale · build_mode
Non-identifying by construction — nothing here reads a device id, and anything
more specific is yours to pass. BugReport.init(deviceFacts: false) sends none
of it. Whatever you pass in metadata wins over what was collected.
Install #
dependencies:
flutter_bug_report: ^0.3.0
With the built-in sheet #
final reportConfig = BugReportConfig(
onSubmit: (bundle, description) => myBackend.upload(bundle),
);
runApp(
BugReportWrapper(
config: reportConfig,
child: MaterialApp(...),
),
);
That is the whole setup. No init, no navigatorKey, no async main: the
wrapper starts collection itself and finds your app's navigator on its own, so
it works wrapped above MaterialApp or inside its builder.
A long press anywhere opens the sheet. trigger: BugReportTrigger.doubleTap or
.none if you would rather open it yourself, and enabled: false — a plain
bool, so a const folds the whole thing out of a release build.
onSubmit is the one thing you must write, and it is the point: the package
builds the file and never decides where it goes.
Everything about a report lives in BugReportConfig — what it sends, what
it says, how it looks — and both the wrapper and BugReportSheet.show take the
same object. Hold one and pass it to both, and the report opened from a settings
row cannot drift away from the one opened by the gesture.
BugReportSheet.show(context, config: reportConfig); // the same report
When the gesture should reach more than the report #
An internal build usually wants the long press to open a menu, with the report
one item on it. onTrigger takes the gesture and is handed the report:
BugReportWrapper(
config: reportConfig,
onTrigger: kReleaseMode
? null // straight to the report
: (context, openReport) => showDebugMenu(context, onReport: openReport),
child: MaterialApp(...),
)
The screenshot, if you asked for one, is captured before your menu opens — so it is a picture of the screen being reported, not of the menu.
Or without any UI #
final bundle = await BugReport.build(
description: whateverTheyTyped,
metadata: {'app_version': '1.0.17+2185'},
);
await myBackend.upload(bundle.bytes, bundle.fileName, bundle.mimeType);
When you want more than the default #
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await BugReport.init( // before runApp, so the log covers startup
redactors: [...Redactor.defaults, Redactor.keys({'merchant_pin'})],
);
runApp(const MyApp());
}
init is where redactors, a persistent store, and capture of debugPrint and
the framework's own errors are switched on. The implicit setup leaves
debugPrint alone on purpose — swapping a global nobody asked for turns up as a
failing assertion in your widget tests.
Logging before init() collects rather than throws or silently drops, and
init() carries those entries forward: the lines that explain a startup bug are
written before anything has had a chance to be configured.
Making it yours #
BugReportConfig(
onSubmit: myUpload,
strings: BugReportStrings(title: l10n.reportTitle, send: l10n.send),
theme: const BugReportTheme(accent: Color(0xFF1B4FD8), radius: 20),
)
Every word is a parameter and every colour falls back to your own ThemeData,
so the sheet reads as part of the app rather than as a package bolted onto it.
When a colour is not enough — you have a design system, and its button is not a
FilledButton — draw the two controls yourself and leave the collecting,
redacting and bundling where it is:
BugReportConfig(
onSubmit: myUpload,
fieldBuilder: (context, controller, enabled) =>
AppTextField(controller: controller, enabled: enabled),
buttonBuilder: (context, onPressed, busy, label) =>
MainButton(onPressed: onPressed, loading: busy, title: label),
)
onPressed is null while the report is in flight or already filed, so a button
that respects it cannot file the same report twice.
And if you want none of the sheet, you already have the headless path — it is
the original API. BugReport.build() returns the bundle; the UI is yours:
final bundle = await BugReport.build(description: whateverTheyTyped);
What it collects #
| Source | How |
|---|---|
| Your own calls | BugReport.debug/info/warning/error(...) |
debugPrint |
automatic — including from plugins and packages you don't control |
bare print |
wrap runApp in ConsoleCapture.runCaptured |
| Flutter errors | FlutterError.onError and PlatformDispatcher.onError |
Capture never displaces what was there before it. The console still prints, and
an existing crash reporter still reports — flutter_bug_report chains onto both.
Redaction #
A bundle leaves the device, so secrets come out on the way in — an entry is rewritten as it's stored, never as it's read. A secret that was never written down can't leak from a store somebody later dumps by hand.
Redactor.defaults covers what it's wrong to ship without:
| Rule | Catches |
|---|---|
| Auth schemes | Authorization headers, Bearer/Basic tokens — including the token after the scheme, not just the word |
| JWTs | eyJ… written out on its own |
| Card numbers | Luhn-checked, so an order id doesn't come out starred. Last four kept |
| Credential keys | password, otp, token, refresh_token, api_key, secret, cvv, cookie, and the rest |
Add your own, or turn it off knowingly:
await BugReport.init(
redactors: [
...Redactor.defaults,
Redactor.pattern(RegExp(r'\+998\d{9}'), replacement: '«phone»'),
Redactor.keys({'merchant_pin'}),
],
);
Bounds #
An attachment nobody can open is no better than none. A bundle is bounded twice over — by entry count and by byte size — and cut from the front, because whatever is being reported happened just before the person wrote it down.
final bundle = await BugReport.build(
limit: 500, // entries
maxBytes: 256 * 1024, // before compression
format: BundleFormat.zip,
);
bundle.truncated; // say so in the ticket: this is the end of a session
bundle.entryCount;
bundle.sizeInBytes;
Size is measured by rendering, not estimated: an entry carrying a stack trace is an order of magnitude larger than one that doesn't, and an average is wrong in both directions.
Formats #
| Contents | For | |
|---|---|---|
BundleFormat.text |
header, then lines, oldest first | a human opening a ticket |
BundleFormat.json |
report + entries |
anything that will index it |
BundleFormat.zip |
logs.txt and report.json |
the default — every tracker takes it |
Take the bytes, or take a file:
bundle.bytes; // Uint8List — for a multipart field or an attachment
bundle.fileName; // log-bundle-20260826-141233.zip
bundle.mimeType; // application/zip
await bundle.writeTo(); // File, in the temp directory by default
Reading one #
A bundle in a ticket still has to be read by somebody, and that should not mean
downloading a zip, unzipping it and scrolling logs.txt in a text editor.
Takes .txt, .json and .zip. Filter by level, search, jump between errors,
and see the metadata and the screenshot beside the log — with a strip across the
top that shows the shape of the session before you read a word of it: the quiet
stretch, the gap where nothing was logged, the burst where it went wrong.
It runs entirely in the page. No upload, no server, no request of any kind — the
page's own Content-Security-Policy forbids one, so it is a property you can
check rather than a promise you have to take. Save the page and it works with no
network at all.
Source, and the
golden bundles it is tested against are this repository's own
test/fixtures/.
Storage #
MemoryLogStore is the default. It keeps nothing on the device: no file to grow
unattended, nothing to clean up, and nothing left behind on a phone that's shared
or sold. It loses everything the process loses.
If you need the log to survive the crash you're chasing, implement LogStore
over sqflite, Hive or a file — five methods, all async by design so a disk-backed
store fits without callers changing shape.
await BugReport.init(store: MyDatabaseLogStore());
Recipes #
Shake to report
flutter_bug_report builds the payload; any gesture package can be the trigger.
ShakeDetector.autoStart(onPhoneShake: (_) async {
final bundle = await BugReport.build(description: await askUser());
await upload(bundle);
});
Attach to Sentry
final bundle = await BugReport.build(description: text);
await Sentry.captureMessage(
text,
withScope: (scope) {
scope.addAttachment(
SentryAttachment.fromUint8List(bundle.bytes, bundle.fileName),
);
bundle.metadata.forEach(scope.setTag); // app_version, platform, the rest
},
);
bundle.metadata is handed back to you rather than only written into the file,
so the facts that belong beside the attachment — as tags on an event, as fields
on a form — do not have to be read back out of a zip.
Send to a Telegram bot
final bundle = await BugReport.build(description: text);
await dio.post(
'https://api.telegram.org/bot$token/sendDocument',
data: FormData.fromMap({
'chat_id': chatId,
'caption': text,
'document': MultipartFile.fromBytes(bundle.bytes, filename: bundle.fileName),
}),
);
Feed it the logger you already have
Most apps do not log through this package — they log through talker, logger
or package:logging, and that is where the HTTP calls are. Without a bridge the
bundle arrives without the most useful thing in it.
BugReport.log is the seam. Map your levels onto LogLevel and forward:
// package:logging
Logger.root.onRecord.listen((r) => BugReport.log(
switch (r.level.value) {
>= 1000 => LogLevel.error,
>= 900 => LogLevel.warning,
>= 800 => LogLevel.info,
_ => LogLevel.debug,
},
r.message,
error: r.error,
stackTrace: r.stackTrace,
));
// talker
talker.stream.listen((e) => BugReport.log(
switch (e.logLevel) {
LogLevel.error || LogLevel.critical => LogLevel.error,
LogLevel.warning => LogLevel.warning,
_ => LogLevel.info,
},
e.generateTextMessage(),
error: e.error,
stackTrace: e.stackTrace,
));
Redaction still runs on the way in, so a bridged logger cannot smuggle a token past it.
One caveat, and it is the only one. A stream delivers asynchronously. Calling
BugReport.info(...) directly and then BugReport.build() is ordered — the
entry is queued synchronously and build waits for the queue. An entry arriving
through a stream is not: it lands whenever the stream gets around to it, which
may be after the bundle was built. If you file a report immediately after the
line you want in it, give the stream a turn first:
await Future<void>.delayed(Duration.zero);
final bundle = await BugReport.build(description: text);
Log Dio requests
Deliberately not a dependency — ten lines, and you decide what's worth recording.
class BugReportInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
BugReport.info(
'${response.requestOptions.method} ${response.requestOptions.path}',
extra: {'status': response.statusCode},
);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
BugReport.error(
'${err.requestOptions.method} ${err.requestOptions.path}',
error: err,
extra: {'status': err.response?.statusCode},
);
handler.next(err);
}
}
Log bloc state changes
class BugReportObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
BugReport.debug(
'${bloc.runtimeType}: ${change.currentState.runtimeType} '
'-> ${change.nextState.runtimeType}',
);
}
}
Capture bare print too
print resolves through the ambient zone, so catching it means running the app
inside one:
void main() async {
await BugReport.init();
ConsoleCapture.runCaptured(
() => runApp(const MyApp()),
(line) => BugReport.debug(line),
);
}
Privacy #
- Nothing is sent anywhere. The package has no network code.
MemoryLogStorewrites nothing to disk.- Redaction runs before storage, not before export.
- Device facts are collected by default and are non-identifying by construction —
platform, OS version, locale, screen, build mode. No device id, no advertising
id, nothing that names a person.
BugReport.init(deviceFacts: false)sends none of it, and anything you pass inmetadatawins over what was collected. Anything more specific than that is yours to add, never ours to guess. BugReport.clear()on sign-out, if the log could name the person who just left.- The viewer sends nothing either. It parses the bundle in the browser and has no network code — the same shape as the package.
License #
MIT © Samandar Ahadjonov