debug_lens 0.0.1
debug_lens: ^0.0.1 copied to clipboard
An on-device debug panel for Flutter: network calls, logs, bloc/navigation events, storage, plus your own crash, analytics and remote-config data.
DebugLens #
An on-device debug panel for Flutter. A draggable bubble opens a view of what your app is actually doing — network calls, logs, bloc transitions, navigation, storage and device facts — plus whatever you push in from your own crash reporter, analytics or remote config, with no console attached and no laptop in the room.
It is built for the people who don't have your IDE open: QA on a test build, a teammate reproducing a bug, you on a phone that isn't plugged in. Every inspector is wired to your own app through a small seam, so DebugLens depends on none of your vendors and drops out cleanly when you remove it.
Install #
dependencies:
debug_lens: ^1.0.0
Dependencies #
| Package | Used for |
|---|---|
| provider | State management behind the panel itself — the Settings, Network and dashboard controllers. |
| dio | Base type for DebugLensDioInterceptor and the cURL export on a call. |
| bloc | Base type for DebugLensBlocObserver. |
| shared_preferences | Persists DebugLens's own on-device state (role, limits, capture switches, bubble position) and backs the default prefs source. |
| share_plus | The share sheet behind every export — logs, crash and health reports, a single call's cURL. |
| path_provider | Writes the temp file share_plus hands off when exporting a report. |
| connectivity_plus | The Network screen's connectivity indicator and the Device & app screen's transport reading. |
| device_info_plus | The Device & app screen's model, manufacturer and OS facts. |
| package_info_plus | The Device & app screen's build and version facts. |
Setup #
Wrap your app and attach the navigator observer. Everything else is opt-in, one inspector at a time.
// The observer feeds Navigation; wrap mounts the bubble and the panel itself.
MaterialApp(
navigatorObservers: [DebugLens.navigatorObserver],
builder: (context, child) => DebugLens.wrap(child ?? const SizedBox.shrink()),
);
| Dashboard | Bubble |
|---|---|
![]() |
![]() |
Implementation: debug_lens.dart · Integration: app.dart
Shipping it #
DebugLens.debugLensEnabled is the one switch that decides whether any of this runs. It
defaults to true; turning it off for the builds you don't want it in is your
call:
void main() {
// The one flag that decides whether any capture path runs at all.
DebugLens.debugLensEnabled = !kReleaseMode; // or: flavor != Flavor.production
runApp(const MyApp());
}
With it off, wrap returns your app untouched and every capture path becomes a
no-op — nothing is stored, nothing is persisted, and neither the remote-config
nor the app-version override is applied, so your own values are what your code
reads. The interceptor, observers and record* calls can stay exactly where they
are; they simply stop writing.
Set it in main, before wrap first builds.
The package deliberately doesn't infer this from the build mode. A QA build is usually a release build, and that is exactly when a tester needs the panel — so guessing would take the decision away from you.
Inspectors #
Network #
Every Dio request and response is captured in full: headers, body, status and duration, with no proxy or extra tooling attached to the device. Calls are grouped per endpoint into a running history, so a duplicate call firing off the same screen, a request repeating on an interval it shouldn't be polling on, or two taps racing to hit the same endpoint all surface as a visible pattern rather than a single log line. The connectivity indicator in the AppBar tells you upfront whether a stalled call is a transport problem or a backend one.
// Captures every request/response this Dio instance makes.
dio.interceptors.add(DebugLensDioInterceptor());
| Network calls | Call detail | Per-endpoint history | Call timeline |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Implementation: debug_lens_dio_interceptor.dart · Integration: api_service.dart
Logs #
A single feed for everything the app says, replacing scattered print calls
with one tagged timeline that lives on the device instead of a terminal. Each
source, your own calls as well as DebugLens's own instrumentation, can be muted
independently from the panel, so isolating one subsystem during a repro doesn't
need a code change. Every push API (crash, analytics, trace, notification)
mirrors into this same feed, so correlating an error against what led up to it
is one list to scroll instead of four to cross-reference.
// Mute the terminal echo once you trust the panel; records still land here.
DebugLensLogger().printToConsole = kDebugMode;
DebugLensLogger().i('Signed in', name: 'auth');
DebugLensLogger().e('Upload failed', name: 'media', error: e, stackTrace: s);
DebugLensLogger() constructs nothing — it hands back the one logger the panel
reads, so there is no global to import and no instance to hold. Don't dispose it:
it is a ChangeNotifier the Logs screen listens to.
Retention limits
Every captured feed, logs included, keeps a fixed number of records before the
oldest ones drop, so a long session doesn't hold an unbounded amount of bodies
and stack traces in memory. DebugLensLimits sets these per feed in one
object; a feed left null keeps its shipped default.
// Seeds the buffer size; a tester can still raise or lower it from Settings.
DebugLens.initialLimits = const DebugLensLimits(logs: 5000, network: 1000);
| Feed | Default | Field |
|---|---|---|
| Network | 250 | network |
| Logs | 1000 | logs |
| Notifications | 200 | notifications |
| Deep-links | 200 | deeplinks |
| Bloc | 200 | bloc |
| Navigation | 500 | navigation |
| Crashes | 100 | crashes |
| Analytics | 100 | analytics |
| Traces | 100 | traces |
Every field accepts 50–5000; a value outside that range is ignored and the default applies. Like the role and its grants, this seeds the first launch only — a limit edited from Settings afterwards wins from then on.
Implementation: debug_lens_limits.dart
| Logs | Capture switches |
|---|---|
![]() |
![]() |
Implementation: debug_lens_logger.dart · Integration: app_log.dart
Bloc #
Every bloc and cubit lifecycle event, created, event received, state
transition, error, closed, is recorded the moment Bloc.observer is set, with
no per-bloc wiring. An intermittent "the UI didn't update" report becomes a
direct comparison instead of a guess: did the event reach the bloc, did the
state actually change, or did the widget just not rebuild. A state that flips
twice for one user action shows up here as two transitions back to back.
// One line covers every bloc and cubit in the app.
Bloc.observer = DebugLensBlocObserver();
Implementation: debug_lens_bloc_observer.dart · Integration: main.dart
Navigation #
Records every route push, pop and replace, and keeps a live view of the navigator stack. A back button that closes the wrong screen, or a route pushed twice under a fast double-tap, shows up here as an actual sequence of events instead of something inferred from watching the screen. Nested navigators, a bottom-nav tab, a shell route, get their own labelled stack, so a leak in one tab's history is never confused with another's.
// Root navigator — see Setup above.
navigatorObservers: [DebugLens.navigatorObserver],
// A nested navigator, e.g. one tab of a shell.
final observer = DebugLens.newNavigatorObserver(label: 'home');
| Route events | Navigator stack |
|---|---|
![]() |
![]() |
Implementation: debug_lens_navigator_observer.dart · Integration: tab_navigator.dart
Storage #
Shows the app's SharedPreferences and its database tables, both read on demand through an adapter you supply, so DebugLens never holds a copy and never imports your storage package. A "stale value after the fix shipped" report becomes answerable on the device itself: is the flag actually persisted, did the migration run, is the row still there. What's on screen is what's on disk right now, not a snapshot from when the panel opened.
// Called on demand, so this always reflects what's on disk right now.
DebugLens.sharedPrefsSource = () => [
for (final key in prefs.getKeys())
DebugLensPrefEntry(key: key, value: '${prefs.get(key)}'),
];
DebugLens.registerDatabase(MyDriftAdapter(db));
| SharedPreferences | Database |
|---|---|
![]() |
![]() |
Implementation: debug_shared_prefs_source.dart, debug_database_source.dart · Integration: prefs_bridge.dart, drift_debug_lens_adapter.dart
Locale #
Renders the app's currently active string map, so a missing key or an untranslated fallback is visible on the device instead of reported secondhand from a screenshot. It's read live on every build, so switching the app's language mid-session updates the screen immediately, turning a locale-switch bug into something reproducible in seconds rather than a restart per language.
// Read live on every build, so a language switch updates the screen instantly.
DebugLens.localeSource = () => DebugLensLocaleData(
entries: currentLangMap,
label: 'English',
);
Implementation: debug_locale_source.dart · Integration: service_locator.dart
Notifications & deep-links #
Two tabs: every notification the app shows or handles, with its raw payload, and every deep-link it opens, broken into scheme, host, path and query. This is what turns "the push arrived but nothing happened" into a diagnosable case: did the payload look wrong, did the link parse into the route you expected, or did navigation just not follow through.
// Call on display and again on tap, so both show up as separate entries.
DebugLens.recordNotification(
title: message.title,
body: message.body,
payload: message.data,
source: 'FCM',
);
DebugLens.recordDeeplink(uri.toString(), source: 'os');
| Notifications | Deep-links |
|---|---|
![]() |
![]() |
Implementation: notification_entry.dart · Integration: notification_service.dart
Services #
A screen of your own for anything DebugLens doesn't already model. Write an adapter when the source can be read back on demand, a cache, a feature-flag client, a queue; push into one of the four built-ins below (remote config, crashes, analytics, traces) when it can't. Either way it's an inspector you define, not a fixed list DebugLens ships with.
// load() is called on demand, not cached, so it's always the live source.
class CacheInspector extends DebugLensService {
@override
String get name => 'API cache';
@override
Future<List<DebugLensServiceGroup>> load() async => [
for (final e in myCache.entries)
DebugLensServiceGroup(title: e.key, values: {'size': '${e.bytes} B'}),
];
}
DebugLens.registerService(CacheInspector());
Implementation: debug_service_source.dart · Integration: mock_firebase.dart
Remote config #
Shows every value fetched from your remote config provider and lets a device
override any of them independently of what the backend actually sent. That's
how a flag-gated bug gets reproduced without waiting on a config rollout or
fighting the provider's own targeting rules: the override applies locally on
the next launch, and the resolved getters (getBool, getInt, ...) always
reflect it.
// Await once at startup — this loads any override saved on a previous run.
await DebugLens.instance.setRemoteConfigData({
for (final e in firebase.getAll().entries) e.key: e.value.asString(),
}, sourceLabel: 'Firebase');
final timeout = DebugLens.instance.getInt('api_timeout_seconds');
Implementation: debug_config_store.dart · Integration: mock_remote_config.dart
Crash reports #
Crash reporters are write-only by design, so DebugLens keeps the same payload you send upstream, stack trace included, right on the device that produced it. Reproducing a crash and reading its stack trace no longer waits on Crashlytics to finish processing the event, or on a tester remembering exactly what they tapped.
// Hand it the exact payload your crash reporter sends upstream.
DebugLens.instance.initCrashReporting();
DebugLens.instance.recordCrash(
DebugLensCrashEvent(error: error, stackTrace: stack, fatal: false),
);
Implementation: debug_crash_store.dart · Integration: mock_crashlytics.dart
Analytics #
Every event you log appears as its own row the moment you call it, with its parameters visible on expand. It answers a specific question during a manual test pass: did this action actually fire the event you expect, with the fields you expect, without waiting hours for it to land in a dashboard.
// The name becomes the row; parameters show when it's expanded.
DebugLens.instance.initAnalytics();
DebugLens.instance.recordAnalyticsEvent(
'add_to_cart',
parameters: {'sku': sku, 'price': price},
);
Implementation: debug_analytics_store.dart · Integration: mock_analytics.dart
Performance #
Finished traces show up with their duration and whatever attributes you attached when the trace stopped. Since you own the running stopwatch, this is how you eyeball whether a screen's load time regressed on this exact device and build, without a performance-monitoring dashboard catching up later.
// You own the stopwatch; push once when the trace stops.
DebugLens.instance.initPerformance();
DebugLens.instance.recordTrace('home_load', stopwatch.elapsed);
Implementation: debug_trace_store.dart · Integration: mock_performance.dart
Device & app #
Model, manufacturer, OS version, screen metrics and the current network transport, gathered once per run with no wiring required. It exists for the one question every bug report needs answered first: what device, what OS, what build was this actually seen on.
Implementation: device_info_source.dart
App version #
Overrides the version string the app reports, so version-gated behaviour, a feature flag tied to a minimum version, a forced-update check, can be reproduced on a device without rebuilding at that version. The override applies from the next app start, and reading it back is the same call your app already uses to display or report its version.
// Await once at startup — this loads any override saved on a previous run.
await DebugLens.instance.setAppVersion(packageInfo.version);
// Read it back wherever the app shows or reports its version.
Text(DebugLens.instance.appVersion);
Implementation: app_version_store.dart · Integration: main.dart
Custom error screen #
Replaces Flutter's red error box with a readable one built for handing off: the exception and its full stack trace are both there, and both copyable straight into a share sheet. A tester who hits a build error can now send you the actual stack trace instead of a screenshot of a wall of red text.
// Replaces Flutter's default red error box wherever a widget fails to build.
ErrorWidget.builder = (details) => CustomErrorScreen(details: details);
Implementation: custom_error_screen.dart · Integration: main.dart
Health check #
Start a window from Settings, reproduce the problem, stop it, and get back every crash and error log recorded in between as a single report. It exists for the reports that start with "something went wrong somewhere in the last few minutes": instead of asking someone to describe what happened, you get the log.
Implementation: health_check_store.dart
Roles #
Developer mode sees every screen; tester mode sees only what a developer has explicitly granted, configured from Settings. This is what makes it safe to hand the panel to a QA build: a tester can't wander into Remote config and edit values meant for someone else, and what they can see is a decision made in code, not whatever they discover by tapping around.
Both the starting role and what a tester may open can be set from code, so a QA build arrives configured instead of needing boxes ticked on the device:
DebugLens.initialRole = DebugRole.developer; // default: tester
DebugLens.initialTesterAccess = { // default: {network}
DebugScreen.network,
DebugScreen.logs,
DebugScreen.device,
};
DebugLens.initialTesterEnabled = false; // default: true
All three seed the first launch only. Once the role has been switched or the
grants edited from Settings on a device, that choice wins — so changing these in
a later release never overrides what someone picked. Set them before wrap
first builds.
DebugScreen covers every panel screen except Settings, which can't be granted:
it is where access is configured, so a tester with it could widen their own.
| Settings | Role picker | Tester access |
|---|---|---|
![]() |
![]() |
![]() |
Implementation: debug_role.dart
Contributing #
Issues and pull requests are welcome at github.com/anupam92402/DebugLens.
Conventions worth knowing #
- User-facing copy lives in debug_strings.dart; non-display constants and preference keys live in debug_constants.dart.
- Features follow
data/,domain/,presentation/views/,presentation/widgets/. Screens go inviews/, everything else inwidgets/. - Reuse the shared widget kit in shared/widgets before adding a new widget — most layouts already have one.
- The panel keeps no copy of data it can ask the host for. If your inspector can read live, give it a source rather than a store.
Credits #
The Dash artwork bundled as one of the bubble icons
(assets/dash.png) is part of the Flutter brand assets, © Google, used under
CC BY 4.0. Flutter and the
Flutter logo are trademarks of Google LLC.

















