stacker_inspector 0.2.0 copy "stacker_inspector: ^0.2.0" to clipboard
stacker_inspector: ^0.2.0 copied to clipboard

Debug-only network, crash and memory-leak inspector for Flutter, native Android, native iOS and hybrid apps. Chucker-style dashboard, BLoC architecture, GetIt DI.

Stacker icon

Stacker #

A Chucker-style debug inspector for API calls, crashes, and memory leaks β€” for Flutter, native Android, native iOS, and hybrid apps.

pub add stacker_inspector for Flutter Β· one Gradle line for native Android

Flutter Dart BLoC DI Tests pub.flutter-io.cn JitPack Release safe


What it does #

🌐 Every API call Status code and what it means, request/response headers, query params, path params, bodies, timings, sizes
πŸ’₯ Crashes Uncaught Flutter, Dart, and native errors with a timestamp and full stack trace
🧠 Memory leaks Retained-object detection via WeakReference, plus resident-memory trend sampling
🍞 Live toasts Each call raises a toast with its status code and endpoint, on all three platforms β€” debug only
🧭 Separate launcher icon A second app icon that opens the dashboard directly β€” debug only
🎯 Intent / deep link Open the dashboard from native code any time
πŸ”’ Release safe Capture, toasts, icon, and timers are all off in release builds

Install #

No Flutter SDK required for the native paths β€” both ship prebuilt binaries.

Your app Channel Install
Flutter / hybrid pub.flutter-io.cn flutter pub add stacker_inspector
Native Android JitPack one Gradle line β€” Β§2
Native iOS CocoaPods one Podfile line β€” Β§3

Table of contents #


Quick start #

# pubspec.yaml
dependencies:
  stacker_inspector: ^0.2.0
import 'package:stacker_inspector/stacker_inspector.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Stacker.init();                      // ← 1. initialise
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
    builder: (context, child) => StackerOverlay(child: child),  // ← 2. toasts + bubble
    home: const HomePage(),
  );
}
// 3. attach to your HTTP client
dio.interceptors.add(StackerDioInterceptor());

That's it. Run in debug and every call appears in the dashboard.


1. Flutter app (2 lines) #

Step 1 β€” initialise #

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Stacker.init();
  runApp(const MyApp());
}

Release builds: init() returns immediately and registers nothing. Leave this line in β€” no if (kDebugMode) guard needed.

Step 2 β€” add the overlay #

MaterialApp(
  builder: (context, child) => StackerOverlay(child: child),
  home: const HomePage(),
)

This adds the per-call toast stack and the draggable layers bubble that opens the dashboard. In release it returns child untouched.

Step 3 β€” attach to your HTTP client #

Dio
final dio = Dio()
  ..interceptors.add(StackerDioInterceptor());   // add LAST

Add it last so it sees the final headers after any auth or retry interceptor has run.

To show path parameters in the dashboard, pass them explicitly β€” Dio interpolates the path before interceptors run, so the template is not recoverable:

dio.get(
  '/users/42/orders',
  options: Options(extra: {
    'stacker.pathParameters': {'userId': '42'},
  }),
);
package:http
final client = StackerHttpClient(inner: http.Client());
final response = await client.get(Uri.parse('https://api.example.com/users'));

Use this client everywhere you would use http.Client(). The response body is buffered for capture and handed back as a fresh stream, so your code reads it normally.

Retrofit (Dart)

Retrofit delegates to Dio, so the Dio interceptor covers it:

final dio = Dio()..interceptors.add(StackerDioInterceptor());
final api = RestClient(dio);
Chopper, GraphQL, or a custom client

Both Chopper and graphql_flutter accept a package:http client:

// Chopper
ChopperClient(client: StackerHttpClient(inner: http.Client()), ...);

// graphql_flutter
HttpLink('https://api.example.com/graphql',
  httpClient: StackerHttpClient(inner: http.Client()));

For anything else, record calls yourself:

Stacker.recordApiCall(ApiRecord(
  id: 'my-call-1',
  method: 'GET',
  url: 'https://api.example.com/thing',
  requestTime: DateTime.now(),
  statusCode: 200,
  responseTime: DateTime.now(),
  state: ApiCallState.complete,
));

2. Native Android (Kotlin/Java) β€” via JitPack #

One dependency line. No Flutter SDK on your machine, no submodules, no manifest edits. In a debug build a second launcher icon appears next to your app's own icon; tapping it opens the full Stacker dashboard.

Step 1 β€” add the repository #

// settings.gradle
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url 'https://jitpack.io' }
        // Hosts the Flutter engine artifacts the dashboard needs.
        maven { url 'https://storage.flutter-io.cn/download.flutter.io' }
    }
}

Step 2 β€” add the dependency #

// app/build.gradle
dependencies {
    def stacker = 'com.github.jatinsinghsatija.Stacker'
    def stackerVersion = 'v0.2.0'

    debugImplementation   "$stacker:stacker_inspector_debug:$stackerVersion"
    debugImplementation   "$stacker:flutter_debug:$stackerVersion"

    // Optional. Omit both lines to keep Stacker out of release builds
    // entirely β€” see "Release builds" below.
    releaseImplementation "$stacker:stacker_inspector_release:$stackerVersion"
    releaseImplementation "$stacker:flutter_release:$stackerVersion"
}

⚠️ Do not use the aggregate coordinate #

JitPack's page will offer you a single line like com.github.jatinsinghsatija:Stacker:v0.2.0. It does not work here. That coordinate resolves to an aggregate POM listing all four modules, so Gradle pulls the debug and release Flutter engines into the same variant and the build fails:

Execution failed for task ':app:mergeDebugNativeLibs'.
> 2 files found with path 'lib/armeabi-v7a/libflutter.so'

Use the four per-variant lines above instead. Note the group id gains a .stacker suffix (com.github.USER.stacker, not com.github.USER) β€” that is JitPack's convention for multi-module builds.

That is the whole integration. The launcher icon, the dashboard activity, and the icon assets all arrive through manifest merging.

Why 1.0 and not the release number? flutter build aar always stamps its output as Maven version 1.0 β€” this is a Flutter toolchain behaviour, not a Stacker choice. You select a release with the JitPack tag, which pins which immutable build you resolve:

maven {
    url 'https://jitpack.io'
    content { includeGroupByRegex 'com\\.stacker.*' }
}

To move between Stacker releases, change the tag JitPack builds (see the JitPack page for the repo), not the 1.0 in these lines.

Requirements β€” please read, these are not optional
Requirement Value Why
compileSdk 36 or higher Set by the Flutter engine AAR, not by Stacker. Building against 35 fails with "requires libraries and applications that depend on it to compile against version 36 or later".
Android Gradle Plugin 8.9+ Needed for compileSdk 36.
minSdk 24+
Debug APK size +~90 MB The Flutter engine plus the compiled dashboard. Debug only. Verified on a bare native app: 49 MB β†’ 141 MB.

If a ~90 MB debug APK is unacceptable for your team, use debugImplementation on a dedicated build variant, or skip the dashboard and consume records yourself β€” see "Capture without the dashboard" below.

Step 3 β€” turn capture on #

One call, in your Application:

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        if (BuildConfig.DEBUG) {
            StackerAndroid.enable(this)
        }
    }
}

StackerAndroid.enable() does four things:

  1. turns capture on, so the OkHttp interceptor starts recording;
  2. shows a per-call toast for every completed request and every crash β€” the same dark card the Flutter overlay draws;
  3. installs the crash handler;
  4. warms the dashboard's Flutter engine so the first open is instant.
Tuning it
StackerAndroid.enable(
    this,
    // ALL (default) Β· ERRORS_ONLY Β· NONE
    toastPolicy = StackerToast.Policy.ERRORS_ONLY,
    // Pass false if you use Crashlytics, Sentry or Bugsnag β€” see the crash caveat.
    installCrashHandler = false,
    // Pass false to skip ~1s of background work at launch.
    warmUpDashboard = false,
)

Toasts are drawn from Kotlin, not routed through Flutter, so they appear on your native screens whether or not the dashboard has ever been opened. Tapping one opens the dashboard on the matching tab.

The launcher icon works without any of this β€” the icon and dashboard function on their own. enable() is what turns on capture and toasts.

Step 4 β€” add the OkHttp interceptor #

val client = OkHttpClient.Builder()
    .addInterceptor(StackerOkHttpInterceptor())   // add LAST
    .build()

Add it last so it sees the final request after any auth or retry interceptor has run. This covers Retrofit too, since Retrofit runs on OkHttp:

Retrofit.Builder()
    .client(client)
    .baseUrl("https://api.example.com/")
    .build()
Interceptor options
StackerOkHttpInterceptor(
    maxBodyBytes = 512L * 1024L,
    redactedHeaders = setOf("authorization", "x-my-secret"),
)

Opening the dashboard from code #

The launcher icon is usually enough, but you can open it directly:

StackerActivity.launch(this, initialTab = "crashes")   // api | crashes | leaks
startActivity(StackerActivity.intent(this, "api"))

By explicit intent β€” from adb, another module, or a debug menu:

val intent = Intent().apply {
    component = ComponentName(packageName, "com.stacker.stacker.StackerActivity")
    putExtra("com.stacker.INITIAL_TAB", "api")
}
startActivity(intent)
adb shell am start -n com.example.myapp/com.stacker.stacker.StackerActivity

Release builds #

Two independent safeguards, both verified against real APKs:

  1. The launcher alias lives in the library's debug source set, so stacker_release.aar does not contain it. It cannot be enabled at runtime because it is not in the artifact.
  2. StackerPlugin refuses to enable the component unless FLAG_DEBUGGABLE is set on the host app.

Measured on a bare native Kotlin app consuming these exact artifacts:

debug APK release APK
Launcher icons 2 (app + Stacker) 1 (app only)
StackerLauncherAlias present absent
ic_stacker resources present 0, stripped

For maximum certainty, omit the two releaseImplementation lines. Stacker is then absent from release builds entirely β€” but note that any code calling StackerActivity or StackerOkHttpInterceptor must then be inside a debug-only source set, or your release build will not compile.

Capture without the dashboard #

StackerOkHttpInterceptor and StackerCrashHandler have no Flutter dependency. If you only want to capture records and render them in your own UI, take stacker_debug alone and omit flutter_debug β€” the capture classes work, and the launcher icon simply opens nothing. This is a deliberate, supported configuration; it is not a drop-in Chucker replacement.


3. Native iOS (Swift) β€” via CocoaPods #

One Podfile line. No Flutter SDK on your machine or on CI β€” the pod ships prebuilt XCFrameworks. Shake the device or tap the floating bubble to open the dashboard.

Step 1 β€” add the pod #

# Podfile
platform :ios, '13.0'

target 'MyApp' do
  use_frameworks!

  pod 'StackerInspector',
      :podspec => 'https://rawgit.flutter-io.cn/jatinsinghsatija/Stacker/v0.2.0/StackerInspector.podspec',
      :configurations => ['Debug']
end
pod install

:configurations => ['Debug'] keeps Stacker out of your release builds entirely β€” the equivalent of Android's debugImplementation.

Use the /Debug subspec on simulators #

The pod ships two framework sets and the choice matters:

Subspec Runs on Why
StackerInspector/Debug simulator + device JIT engine with a Dart kernel blob
StackerInspector/Release device only AOT engine; Apple's AOT compiler does not target the simulator

StackerInspector/Debug is the default, so plain pod 'StackerInspector' also works. Pinning /Release and then running on a simulator fails with Engine run configuration was invalid and a black dashboard β€” verified, and the reason both sets are shipped.

Requirements β€” please read
Requirement Value Why
Deployment target iOS 13+ Flutter engine minimum.
use_frameworks! required The pod vends dynamic XCFrameworks.
First pod install ~142 MB download Both the Debug (JIT, simulator-capable) and Release (AOT) framework sets. CocoaPods caches it, so this is a one-time cost per machine.
Xcode 15+

If a :configurations => ['Debug'] pod is not viable for your setup, see "Release builds" below for the alternative.

Step 2 β€” enable it #

One call, in AppDelegate:

import StackerInspector

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        #if DEBUG
        StackerAutoAttach.enable()
        #endif
        return true
    }
}

StackerAutoAttach.enable() does four things:

  1. turns capture on;
  2. registers StackerURLProtocol globally, so URLSession.shared traffic is captured;
  3. installs a shake gesture that opens the dashboard from any screen;
  4. shows a draggable floating bubble as a second way in.
StackerAutoAttach.enable(showBubble: false)   // shake gesture only

Why no second home-screen icon on iOS? iOS has no equivalent of Android's activity-alias. The only supported way to change an app's icon is setAlternateIconName, which replaces the app's own icon and shows a system alert every time β€” unusable for a debug tool. The shake gesture and bubble are the standard iOS substitutes.

Step 3 β€” capture URLSession traffic #

StackerAutoAttach.enable() already calls registerGlobally(), which reaches URLSession.shared and any session built from a default configuration β€” covering Alamofire and most SDKs.

It does not reach a session whose protocolClasses were replaced wholesale, nor a background session; the OS loads those outside the app's URL loading system. For those, install into the configuration directly:

let config = URLSessionConfiguration.default
StackerURLProtocol.install(in: config)
let session = URLSession(configuration: config)

Step 4 β€” crash capture (optional) #

#if DEBUG
StackerCrashHandler.install()
#endif

⚠️ Do not call this if you already use Crashlytics, Sentry, or Bugsnag. They install their own handlers and whichever installs last wins the signal path. Use StackerCrashHandler.recordNonFatal(_:) for handled errors instead, and let the dedicated SDK own fatal crashes. See Crash capture for the full caveat.

Opening the dashboard from code #

StackerAutoAttach.openDashboard(initialTab: "crashes")   // api | crashes | leaks
StackerDashboardPresenter.shared.present(initialTab: "api")

Via a URL scheme β€” add one to a debug-only Info.plist, then:

func application(_ app: UIApplication, open url: URL, options: ...) -> Bool {
    #if DEBUG
    if url.scheme == "stacker" {
        StackerAutoAttach.openDashboard(initialTab: url.host ?? "api")
        return true
    }
    #endif
    return false
}
xcrun simctl openurl booted stacker://crashes

Release builds #

:configurations => ['Debug'] is the primary safeguard β€” the pod is not linked into release builds at all, so nothing to strip.

If you must link it in every configuration, the runtime gate still holds: capture stays off until StackerAutoAttach.enable() is called, so guarding that one call with #if DEBUG is sufficient. The bubble and shake gesture are both no-ops while capture is disabled.

If the dashboard renders letterboxed #

If the dashboard (or your whole app) appears inset with black bands top and bottom, your app is running in iOS compatibility mode β€” a legacy 4.7" canvas β€” not full screen. This is an app-level setting, not something Stacker controls.

Your Info.plist needs a launch-screen declaration. Modern apps use:

<key>UILaunchScreen</key>
<dict/>

Older projects use UILaunchStoryboardName pointing at a storyboard. With neither key present, iOS letterboxes the app and every view inside it, including the dashboard.

Debugging note #

Flutter needs an LLDB init file to debug correctly when embedded on recent iOS versions. If you hit breakpoint oddities in your own code after adding Stacker, follow Flutter's Set LLDB Init File step. This affects debugging only, never runtime behaviour.


4. Hybrid / add-to-app #

Already have Flutter screens inside a native app? Do both:

  1. Native side β€” the OkHttp interceptor (Android) or StackerURLProtocol (iOS) for native traffic.
  2. Flutter side β€” Stacker.init() plus the Dio/http interceptor for Dart traffic.

Both feed the same dashboard. Each row shows an origin badge β€” πŸ€– ANDROID, πŸ“± IOS, or πŸ’™ DART β€” so you can tell which layer made the call.

Startup calls are not lost. Native requests made before the Flutter engine attaches are buffered (up to 200) and drained into the dashboard as soon as Dart connects.


The debug-only launcher icon #

A second app icon that opens the dashboard straight from the launcher, exactly like Chucker.

Android β€” automatic #

You do not add any manifest XML. The activity-alias ships inside the library's own debug source set, so it merges into your app the moment you add the dependency. Add Stacker, build a debug APK, and a second icon appears on the home screen.

The @mipmap/ic_stacker_launcher icon ships with the library β€” three offset plates under a magnifier, so the stack reads at launcher size and is never mistaken for your own app icon. The mark above is the same artwork (doc/stacker_icon.svg), generated from the launcher vector so the two cannot drift apart.


Two independent safeguards keep it out of production:

  1. The alias is in the library's debug source set, so stacker_release.aar does not contain it at all. It cannot be turned on at runtime because it is not in the artifact.
  2. StackerPlugin refuses to enable the component when FLAG_DEBUGGABLE is absent, even if asked.
Customising the label or icon

Override the string resource in your own src/debug/res/values/strings.xml:

<resources>
    <string name="stacker_launcher_label">Debug tools</string>
</resources>

For a different icon, declare your own alias in src/debug/AndroidManifest.xml pointing at com.stacker.stacker.StackerActivity with your own android:icon, and disable the bundled one:

<activity-alias
    android:name="com.stacker.stacker.StackerLauncherAlias"
    android:enabled="false"
    tools:node="merge" />

iOS β€” shake or bubble instead #

iOS has no activity-alias equivalent, so no library can add a second home-screen icon. setAlternateIconName replaces the app's own icon and shows a system alert on every call, which rules it out for a debug tool.

The substitutes, all debug-gated:

Mechanism Native iOS Flutter
Shake the device StackerAutoAttach.enable() β€”
Floating bubble StackerAutoAttach.enable() StackerOverlay
URL scheme debug-only Info.plist same
Code StackerAutoAttach.openDashboard() Stacker.openDashboard(context)

Opening the dashboard #

From Code
Flutter Stacker.openDashboard(context)
Flutter (specific tab) Stacker.openDashboard(context, initialTab: DashboardTab.leaks)
Flutter β†’ native host Stacker.openNativeDashboard()
Android StackerActivity.launch(context, "api")
iOS (native) StackerAutoAttach.openDashboard(initialTab: "api")
iOS β€” shake shake the device, after StackerAutoAttach.enable()
iOS β€” bubble tap the floating button
In-app bubble Tap the layers button (from StackerOverlay)
Launcher Tap the Stacker launcher icon (Android debug)
A toast Tap it to jump to that record
As a route MaterialPageRoute(builder: (_) => const StackerDashboard())

Memory leak detection #

Two mechanisms with deliberately different confidence levels.

1. Retained objects β€” CONFIRMED #

Register an object, then declare when it should be gone. If it is still reachable after the retention window and three check cycles, something holds a strong reference to it. That is proof, not a guess β€” the same principle LeakCanary uses.

class _MyPageState extends State<MyPage> {
  @override
  void initState() {
    super.initState();
    Stacker.watchForLeaks(this, label: 'MyPage');
  }

  @override
  void dispose() {
    Stacker.expectDisposed(this);   // ← "this should now be garbage"
    super.dispose();
  }
}

Works for anything with a lifecycle β€” BLoCs, controllers, repositories:

class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc() : super(MyState()) {
    Stacker.watchForLeaks(this);
  }

  @override
  Future<void> close() {
    Stacker.expectDisposed(this);
    return super.close();
  }
}

Only a WeakReference is kept, so watching an object never keeps it alive and never causes the leak it is looking for.

Typical culprits: an uncancelled StreamSubscription, a live Timer, an AnimationController that was never disposed, a listener still registered, or a closure capturing this stored in a long-lived singleton.

2. Growing resident memory β€” SUSPECTED #

Resident memory is sampled on an interval. Six consecutive rises totalling more than 24 MB, with no fall-back, are reported as suspected β€” never confirmed, because a warming image or response cache produces the same shape.


Crash capture #

Flutter and Dart β€” reliable #

Stacker.init() installs three handlers, covering all three escape routes:

Hook Catches
FlutterError.onError Framework errors (build, layout, paint, gestures)
PlatformDispatcher.onError Uncaught async Dart errors
Isolate.addErrorListener Errors that bypass both

Any existing handler is chained, so Crashlytics, Sentry, and the red screen keep working.

Record a handled error explicitly:

try {
  await riskyOperation();
} catch (error, stackTrace) {
  Stacker.recordError(
    error,
    stackTrace,
    context: 'Syncing the cart',
    metadata: {'cartId': cart.id},
  );
}

Native crashes β€” read the caveat #

⚠️ Native fatal crashes are best-effort. Expand for the honest details.

Android (StackerCrashHandler.install()) hooks Thread.setDefaultUncaughtExceptionHandler and chains to the previous handler.

iOS (StackerCrashHandler.install()) hooks NSSetUncaughtExceptionHandler and sigaction for SIGABRT/SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGTRAP.

Three real limitations:

  1. Records are in-memory only. They do not survive the crash. Reopening the app shows an empty crash list. A crash caught while the dashboard is already open is usually visible; a crash during startup generally is not.
  2. A signal handler may only call async-signal-safe functions. Sending a record over a method channel is not one of them. The signal path is deliberately minimal and delivery is not guaranteed.
  3. It conflicts with Crashlytics, Sentry, and Bugsnag, which install their own handlers. Whichever installs last wins the signal path.

Recommendation: if you already use a real crash reporter, do not call install(). Use recordNonFatal() for handled errors and let the dedicated SDK own fatal crashes. Stacker's crash tab is then a live view of Flutter-side errors during a debug session, which is what it is genuinely good at.


Configuration #

Every field has a sensible default; const StackerConfig() is production-safe.

await Stacker.init(
  config: const StackerConfig(
    // Buffer sizes (fixed-capacity ring buffers, oldest evicted)
    maxApiRecords: 200,
    maxCrashRecords: 100,
    maxLeakRecords: 100,

    // Toasts
    toastPolicy: ToastPolicy.always,        // always | errorsOnly | never
    toastDuration: Duration(seconds: 3),

    // UI
    showLauncherBubble: true,

    // Detectors
    captureCrashes: true,
    detectLeaks: true,
    leakRetentionWindow: Duration(seconds: 8),
    memorySampleInterval: Duration(seconds: 5),

    // Payload limits
    maxBodyLength: 250 * 1024,

    // Redaction
    redactedHeaders: StackerConfig.defaultRedactedHeaders,
    redactedBodyKeys: StackerConfig.defaultRedactedBodyKeys,
    redactionPlaceholder: 'β€’β€’β€’ redacted β€’β€’β€’',
  ),
);

Only want toasts for failures?

await Stacker.init(
  config: const StackerConfig(toastPolicy: ToastPolicy.errorsOnly),
);

Reading captured data programmatically:

final failures = Stacker.apiRecords.where((r) => !r.isSuccess);
final report   = Stacker.apiRecords.first.toReport();
final curl     = Stacker.apiRecords.first.toCurl();

Security: read this #

Secrets are redacted at capture time #

Authorization, Cookie, X-Api-Key, and friends β€” plus body keys like password, accessToken, clientSecret β€” are replaced before anything reaches the buffer. A redacted value never exists in memory, so it cannot leak through a shared report or a screenshot.

Redaction walks nested JSON, so {"data":{"user":{"accessToken":"…"}}} is caught too.

What redaction does not cover. Matching is by key name, applied to request headers, query parameters, and request bodies. A secret that appears in a response body under a key Stacker does not know about is stored as received β€” if your login endpoint returns {"jwt": "…"}, add jwt to redactedBodyKeys. Likewise a token embedded in free-form text, or in a key you have not listed, is not detected. Redaction is a strong default, not a guarantee: review the list against your own API surface.

Add your own:

const StackerConfig(
  redactedHeaders: {...StackerConfig.defaultRedactedHeaders, 'x-internal-token'},
  redactedBodyKeys: {...StackerConfig.defaultRedactedBodyKeys, 'nationalId'},
)

Nothing is written to disk #

Records live in memory for the process lifetime only. No storage permission is needed and no captured token is left on the device after the app exits.

⚠️ enabledOverride #

const StackerConfig(enabledOverride: true)   // DANGER

This forces capture on in release builds. That means real user request and response bodies, and any header you have not added to the redaction list, held in the memory of a shipped app β€” with a dashboard that anyone holding the phone can open.

Only use it for an internal QA build that never reaches a store, and treat that build as containing production data. Never ship it.


Architecture #

lib/
β”œβ”€β”€ stacker.dart                    # public API barrel
└── src/
    β”œβ”€β”€ core/
    β”‚   β”œβ”€β”€ http_status.dart         # status code β†’ meaning (all IANA + Cloudflare/nginx)
    β”‚   β”œβ”€β”€ stacker_config.dart
    β”‚   β”œβ”€β”€ redactor.dart            # capture-time secret removal
    β”‚   β”œβ”€β”€ id_generator.dart
    β”‚   └── service_locator.dart     # GetIt container
    β”œβ”€β”€ data/
    β”‚   β”œβ”€β”€ models/                  # ApiRecord, CrashRecord, LeakRecord
    β”‚   β”œβ”€β”€ repository/              # RingBuffer + StackerRepository
    β”‚   └── sources/                 # method + event channel bridge
    β”œβ”€β”€ domain/
    β”‚   β”œβ”€β”€ crash_reporter.dart      # 3 error hooks, chained
    β”‚   └── leak_detector.dart       # WeakReference retention + RSS trend
    β”œβ”€β”€ interceptors/                # Dio interceptor, http client wrapper
    └── presentation/
        β”œβ”€β”€ blocs/                   # api_list, crash_list, leak_list
        β”œβ”€β”€ screens/                 # dashboard, list + detail per tab
        └── widgets/                 # overlay (toasts + bubble), shared UI

BLoC #

Each tab has a bloc with explicit events and states:

// Events                              // State
ApiListSubscriptionRequested           ApiListState(
ApiListUpdated                           status, records, query, filter
ApiListSearchChanged                   )
ApiListFilterChanged                   β†’ state.visibleRecords
ApiListCleared

The repository is the single source of truth; blocs mirror it and layer search and filtering on top. Filtering lives in the state's visibleRecords getter, so changing a filter never discards captured data.

Dependency injection #

A dedicated GetIt instance (GetIt.asNewInstance()), not GetIt.instance β€” your app probably uses GetIt too, and a shared container would mean Stacker's registrations and yours fighting over the same namespace.

StackerLocator.get<StackerRepository>();   // singleton
StackerLocator.get<ApiListBloc>();          // factory β€” fresh per route

Testing #

159 tests, all passing:

ring_buffer          10   capacity, eviction, in-place replacement
redactor             15   headers, nested JSON, form bodies, truncation
http_status          11   every class, boundaries, unofficial codes
repository           15   ordering, capacity, streams, post-dispose safety
api_list_bloc        15   events, states, filters, search
leak_detector        14   real retention detection + false-positive suppression
crash_reporter       14   3 hooks, handler chaining, native parsing
dio_interceptor      14   against a real loopback HTTP server
http_client           8   stream re-emission verified
service_locator      12   DI wiring, isolation, hot restart
overlay_navigation    4   both dashboard entry points actually navigate
overlay_toast        10   toast content, policies, capping, tap-to-open
dashboard_widget     17   full UI, navigation, empty states
flutter test

What Stacker does not do #

Being explicit so you can pick the right tool:

Not supported Use instead
Full retaining paths for a leak Flutter DevTools β†’ Memory β†’ heap snapshot
Crash reports surviving a restart Crashlytics, Sentry
Capturing native traffic from a background URLSession (iOS) StackerURLProtocol.install(in:) on that session
Capturing traffic from a session with protocolClasses replaced (iOS) Same
Capturing raw sockets / gRPC over HTTP2 Not intercepted
Persisting records across app restarts By design β€” nothing touches disk
A second home-screen icon on iOS OS limitation β€” use the shake gesture or bubble
Production monitoring Datadog, New Relic

Troubleshooting #

No calls appear in the dashboard
  1. Confirm capture is on: print(Stacker.isEnabled). It is false in release.
  2. Confirm await Stacker.init() ran before the request.
  3. Confirm the interceptor is attached to the same client instance making calls.
  4. On Android, confirm StackerOkHttpInterceptor is on the OkHttpClient your Retrofit instance actually uses.
The launcher icon does not appear (Android)
  1. The activity-alias must be in src/debug/AndroidManifest.xml, not src/main/.
  2. The app must be debuggable β€” check BuildConfig.DEBUG.
  3. Some launchers cache the icon list; try relaunching the launcher.
  4. Verify the merged manifest: ./gradlew :app:processDebugManifest then inspect app/build/intermediates/merged_manifests/.
No toasts

StackerOverlay must wrap your app via MaterialApp.builder, and toastPolicy must not be never. A toast only fires for a completed call, never a pending one.

A leak is not reported

You need both halves: watchForLeaks at creation and expectDisposed at the end of life. Reporting also requires the retention window (default 8 s) to elapse and three check cycles at memorySampleInterval (default 5 s) β€” so allow roughly 20 seconds.

ProviderNotFoundException when opening a record

Only happens if you push ApiDetailScreen yourself. It needs ApiListBloc in scope:

BlocProvider.value(
  value: context.read<ApiListBloc>(),
  child: ApiDetailScreen(recordId: id),
)

Pushing StackerDashboard (the supported path) handles this for you.

Response body is empty for a large download

Bodies are truncated at maxBodyLength (250 KB default) so capture cannot balloon memory. Raise it if you need more, or expect binary payloads to show as <binary N bytes>.

iOS: pod install is slow or the download is huge

Expected. The pod is ~142 MB because it carries two complete framework sets: Debug (JIT, the only one that runs on a simulator) and Release (AOT, for device builds). Shipping Release alone would black-screen every simulator, so both are required. CocoaPods caches it per machine, so only the first pod install pays that cost.

iOS: "No such module 'StackerInspector'"
  1. use_frameworks! must be present in the Podfile β€” the pod vends dynamic frameworks.
  2. Open the .xcworkspace, never the .xcodeproj.
  3. If you used :configurations => ['Debug'], the module only exists in Debug builds. Any code importing it must be inside #if DEBUG.
iOS: shake gesture does nothing
  1. StackerAutoAttach.enable() must have run β€” it is what installs the hook.
  2. On the simulator, use Device β–Έ Shake rather than moving the mouse.
  3. If your app already swizzles motionEnded, whichever installs last wins. Use the bubble or call openDashboard() from your own debug menu instead.
iOS: breakpoints behave oddly after adding Stacker

Flutter needs an LLDB init file when embedded on recent iOS versions. Follow Flutter's Set LLDB Init File step. This affects debugging only, not runtime behaviour.


Publishing (maintainers) #

Full step-by-step guide: RELEASE.md. It assumes no prior publishing experience and covers all three channels, including what "success" looks like at each step.

One repository, three channels:

Channel Command Notes
pub.flutter-io.cn flutter pub publish Permanent β€” a version can never be reused. Publish 0.2.0-dev.1 first.
JitPack git tag v0.2.0 && git push origin v0.2.0 Builds on demand; nothing to upload. First build takes 10–20 min.
CocoaPods ./scripts/build_ios_frameworks.sh 0.2.0 Attach the resulting ~142 MB zip to the GitHub Release.

Quick pre-flight:

flutter analyze && flutter test && flutter pub publish --dry-run

Expect 0 warnings and an archive around 140 KB. If it is megabytes, .pubignore is broken β€” it exists to keep stacker_host/ out of the package.

.github/workflows/ci.yml asserts the properties that matter most, so a regression fails CI rather than shipping:

  • the Android launcher alias is present in stacker_debug and absent from stacker_release;
  • the published POM does not pin kotlin-stdlib onto consumers;
  • the pub.flutter-io.cn archive stays small.

License #

See LICENSE.

0
likes
140
points
79
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Debug-only network, crash and memory-leak inspector for Flutter, native Android, native iOS and hybrid apps. Chucker-style dashboard, BLoC architecture, GetIt DI.

Repository (GitHub)
View/report issues

Topics

#networking #debugging #devtools #logging #bloc

License

MIT (license)

Dependencies

bloc, dio, equatable, flutter, flutter_bloc, get_it, http, plugin_platform_interface

More

Packages that depend on stacker_inspector

Packages that implement stacker_inspector