Integrate an SDK · Flutter

Flutter SDK

Everything ships in one package, bug_capsule 0.4.0, with one import: the runtime, the pure-Dart core, every integration and the bugcapsule CLI. Capsules still report sdk.name: bug_capsule_flutter, so older CLIs read them as before.

Requirements
Dart SDK ^3.9, Flutter ≥ 3.35
Import
import 'package:bug_capsule/bug_capsule.dart';
Pinned ranges
bloc ^9, riverpod ^3, get ^4.6, mobx ^2.3, dio ^5.9, http ^1.5, go_router 14–18, shared_preferences ^2.5
!

The package depends on every library it integrates with, so your app's versions must fall in the ranges above even for libraries you don't use. Unused code is tree-shaken out of release builds. If pub get fails on a version conflict, this is usually why.

1. Add the dependency

dependencies:
  bug_capsule:
    git:
      url: https://github.com/draz26648/bug_capsule_flutter_sdk
      path: packages/bug_capsule

2. Initialize: the first line of main

import 'package:bug_capsule/bug_capsule.dart';

final bugCapsuleDio = BugCapsuleDio(rules: [
  // Fields that differ on every run must not affect request matching.
  EndpointRule('POST', '/api/checkout', ignoreBodyFields: {'timestamp', 'nonce'}),
]);
final bugCapsuleRouter = BugCapsuleGoRouter();

void main() => BugCapsule.init(              // first line of main, before ensureInitialized
      config: const BugCapsuleConfig(
        appVersion: '1.4.0',
        appBuild: '52',
        mode: CaptureMode.qa,                  // or --dart-define=BUGCAPSULE_MODE=qa
        backend: BugCapsuleBackendConfig(url: 'https://api.bugcapsule.com', ingestKey: 'bci_...'),
      ),
      integrations: [
        BugCapsuleBloc(states: [
          BugCapsuleState<CartBloc, CartState>(toJson: (s) => s.toJson(), fromJson: CartState.fromJson),
        ]),
        bugCapsuleDio,
        bugCapsuleRouter,
        BugCapsuleSharedPreferences(allowPrefixes: ['cart.', 'onboarding_']),
      ],
      appRunner: () => runApp(const MyApp()),
    );

Then, where you build Dio and GoRouter, and for the QA capture button:

bugCapsuleDio.attach(dio);                       // after any custom adapter; safe before or after init

final router = GoRouter(initialLocation: bugCapsuleRouter.initialLocation('/'), routes: [...]);
bugCapsuleRouter.attach(router);

MaterialApp.router(
  routerConfig: router,
  builder: (context, child) => BugCapsuleOverlay(child: child!),   // QA capture button
);

3. Build with the commit pinned

flutter build ipa \
  --dart-define=BUGCAPSULE_GIT_SHA=$(git rev-parse HEAD) \
  --dart-define=BUGCAPSULE_MODE=qa

The SDK reads four dart-defines: BUGCAPSULE_MODE, BUGCAPSULE_GIT_SHA, BUGCAPSULE_GIT_DIRTY and BUGCAPSULE_REPLAY_URL (set by the CLI). An invalid BUGCAPSULE_MODE value throws. The demo app also reads BUGCAPSULE_BACKEND_URL and BUGCAPSULE_INGEST_KEY, but those are the demo's own; wire your backend in code as above.

State management

All integrations come with the package; use the ones that match your app and combine them freely (for example Riverpod with package:http).

BugCapsuleBloc(
  states: [
    BugCapsuleState<CartBloc, CartState>(
      toJson: (s) => s.toJson(),
      fromJson: CartState.fromJson,
      eventToJson: (e) => e.toJson(),          // optional
    ),
  ],
  recordStateData: null,                       // default: true except in production
  maxStateBytes: 64 * 1024,
  treatErrorsAsFailures: true,
)

Installs a global BlocObserver (chaining the previous one). No per-bloc setup. Events, states and errors become bloc.* events; registered states go into periodic checkpoints and are restored by creation order (CartBloc#0).

final bugCapsuleRiverpod = BugCapsuleRiverpod(states: [
  BugCapsuleRiverpodState<Cart>(cartProvider, toJson: (c) => c.toJson(), fromJson: Cart.fromJson),
]);

// in init: integrations: [bugCapsuleRiverpod, ...]
appRunner: () => runApp(ProviderScope(observers: [bugCapsuleRiverpod.observer], child: const MyApp())),

NotifierProvider, AsyncNotifierProvider, StreamNotifierProvider (and families) restore on their own. Providers from riverpod_generator need a restore: callback and a registration. id defaults to provider.name; with neither, the constructor throws. An AsyncNotifier whose build finishes after the restore overwrites the restored state.

final bugCapsuleNotifiers = BugCapsuleNotifiers();

ChangeNotifierProvider(
  create: (_) => bugCapsuleNotifiers.track(CartModel(), id: 'cart',
      toJson: (m) => m.toJson(),
      restore: (m, json) => m.replaceWith(CartModel.fromJson(json))),
  child: ...,
);

Works with any Listenable (ChangeNotifier, ValueNotifier). Objects tracked before init are not recorded. untrack(object) stops tracking.

final bugCapsuleGetx = BugCapsuleGetx();

class CartController extends GetxController {
  final coupon = bugCapsuleGetx.trackRx(''.obs, id: 'cart.coupon');    // primitives need no serializer
  final items = bugCapsuleGetx.trackRx(<CartItem>[].obs, id: 'cart.items',
      toJson: (items) => {'items': [for (final i in items) i.toJson()]},
      fromJson: (json) => [for (final i in json['items'] as List) CartItem.fromJson(i as Map<String, Object?>)]);
}
// GetBuilder controllers: bugCapsuleGetx.track(controller, id: 'cart', toJson: ..., restore: ...)

update([ids]) is not seen, only update().

final bugCapsuleMobx = BugCapsuleMobx();   // spy-based: observable writes, actions, reaction errors recorded on their own

final cart = bugCapsuleMobx.track(CartStore(), id: 'cart',
    toJson: (s) => s.toJson(),
    restore: (s, json) => s.replaceWith(json));   // restore runs inside an action

Observable writes are redacted by field name (_LoginStore.password). Options: recordObservables, treatReactionErrorsAsFailures, context.

Redux has no integration: its state is re-executed by replay but not checkpointed or restored.

HTTP and navigation

LibraryIntegrationWiring
dio, retrofitBugCapsuleDioattach(dio) wraps dio.httpClientAdapter (not an interceptor). Call it after setting a custom adapter.
http, chopper, anything taking an http.ClientBugCapsuleHttpclient() or client(existingClient). Same request keys and events as Dio.
go_router 14–18BugCapsuleGoRouterinitialLocation('/') + attach(router) (listens to the router delegate). sanitizeLocation: for secrets in paths.
shared_preferencesBugCapsuleSharedPreferencesSee the note below.
final bugCapsuleHttp = BugCapsuleHttp(
  rules: [EndpointRule('POST', '/api/checkout', ignoreBodyFields: {'nonce'})],
  exclude: (req) => req.url.path.startsWith('/api/payments'),   // record method/URL/status/timing only
);
final client = bugCapsuleHttp.client();
final chopper = ChopperClient(client: bugCapsuleHttp.client(), ...);

Both HTTP integrations take rules, ignoreQuery (default _ ts timestamp nonce cachebust), ignoreBodyFields, maxBinaryBytes and maxKeyBodyBytes (64 KiB), maxBodyWait (30 s) and exclude. EndpointRule(method, path, {ignoreQuery, ignoreBodyFields, matchBodyFields}) accepts * as method and a trailing * in the path.

!

BugCapsuleSharedPreferences() with no arguments captures every key (default allowPrefixes: [''], only flutter.bugcapsule.* denied). Pass explicit allowPrefixes for anything but a demo.

Not captured: dart:io HttpClient used directly, cupertino_http/cronet_http without an http.Client wrapper, GraphQL subscriptions, WebSockets, platform channels, native SDKs (Firebase, maps, payments).

Make more bugs eligible

HookWhy
onPressed: BugCapsule.action('cart.checkout', () => ...)Semantic replay when the developer's screen differs from QA's. actionWithPayload(id, (payload) => ...) for actions with data.
BugCapsule.trackText('login.email', controller)Typed text is replayed. Sensitive ids (password, otp, …) are redacted; sensitive: true redacts any field. Use it for every obscured field. untrackText when disposing.
BugCapsule.check('cart_total_non_negative', total >= 0) / BugCapsule.fail(id)Wrong behaviour that doesn't throw becomes a checkable failure
clock.now() (package clock) instead of DateTime.now()Replay restores the recorded wall clock
BugCapsule.random instead of Random()Seeded per session, same seed in replay

Uncaught errors are captured from FlutterError.onError (non-silent) and PlatformDispatcher.onError, both chained to the previous handlers. A capsule is written 300 ms after a new failure signature, at most 3 per session.

BugCapsule.log('legacy cache migrated', level: 'warn');
final result = await BugCapsule.capture(note: 'QA: total looks wrong');   // null when not recording
await BugCapsule.flushUploads();
await BugCapsule.refreshRemoteConfig();
final files = await BugCapsule.savedCaptures();                          // newest 20

Configuration reference

const BugCapsuleConfig(...)

FieldDefaultNotes
appVersion, appBuildrequired
appId, appName, flavornull
modeBUGCAPSULE_MODE, else kReleaseMode ? production : debugDebug and QA always record
enabledtrueLocal master switch: no capture code runs when false
backendnullBugCapsuleBackendConfig(url:, ingestKey:). Without it: nothing is uploaded and capture settings aren't fetched
gitShaBUGCAPSULE_GIT_SHAgitDirty from BUGCAPSULE_GIT_DIRTY=true
stateSchemaVersion1Bump when serialized state changes shape
checkpointInterval / window / maxEvents30 s / 5 min / 20 000
productionSampleRate0.1
autoCaptureOnFailure / maxCapsulesPerSessiontrue / 3
captureNetworkBodies / maxBodyBytestrue / 256 KiB
redactKeys, redactHeaders, redactPatterns[]redactPatterns: List<RegExp>
beforeRecord(type, data)nullReturn null (or throw) to drop the event
beforeCheckpoint(integration, data)nullReturn null to leave that integration out
outboxLimitsOutboxLimits() 10 / 10 MiB / 7 days
maxStaleConfig7 days
showOverlaymode ≠ production
ignoreError(error)null
labels{}Scrubbed like free text
replayUrlnullTests only

Replay specifics

Writing a custom integration

class SecureStoreIntegration extends BugCapsuleIntegration {
  @override
  String get name => 'secure_store';

  @override
  Future<void> setUp(BugCapsuleSession session) async {
    // record diagnostic events: session.record('secure_store.read', {'key': key});
  }

  @override
  Object? snapshot(CheckpointKind kind) => {'flags': _flags};      // goes to checkpoint.data['secure_store']

  @override
  Future<void> restore(Object? data, BugCapsuleSession session) async { _flags = ...; }

  @override
  void describeEligibility(EligibilityBuilder eligibility, RecordedWindow window) {}
}

Each checkpoint.data key belongs to one integration; only that integration reads it back. Data you return passes through redaction and beforeCheckpoint.

Demo app

example/bug_capsule_demo is a shop with planted "cannot reproduce" bugs.

BugHow to triggerWhat replay needs
Profile/recommendations raceTap Refresh picks until it crashes (~1 in 4)Recorded response latencies
Legacy cart cacheSimulate upgrade from v0.9, restart, open cartStorage from the launch checkpoint
Negative totalOpen cart until subtotal < $100, apply SAVE100Typed text + server cart + custom assertion
Discontinued itemOpen cart until "unavailable" shows, checkoutThe server response with price: null
Orders stuck loadingOpen My orders until it spins > 4 sThe recorded 202 + watchdog assertion

Upgrading

From the 12-package layout to 0.4 (one package)
From 0.1