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_router14–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
);
initcallsWidgetsFlutterBinding.ensureInitialized()itself and runs your app insidewithClock, soclock.now()is the replay clock.- Startup order: look for a replay request (ignored in release builds) → open storage, outbox and cached remote config, set up integrations, take the launch checkpoint, install error handlers and the pointer route →
appRunner()→ refresh remote config → flush the outbox. - Capture setup has a 5 s budget. If it fails or times out the app runs without capture (reason printed with
debugPrint). A secondinitthrowsStateError. - The overlay only shows when
showOverlayis true (default: any mode but production), the app is recording, and not replaying.
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
| Library | Integration | Wiring |
|---|---|---|
dio, retrofit | BugCapsuleDio | attach(dio) wraps dio.httpClientAdapter (not an interceptor). Call it after setting a custom adapter. |
http, chopper, anything taking an http.Client | BugCapsuleHttp | client() or client(existingClient). Same request keys and events as Dio. |
go_router 14–18 | BugCapsuleGoRouter | initialLocation('/') + attach(router) (listens to the router delegate). sanitizeLocation: for secrets in paths. |
shared_preferences | BugCapsuleSharedPreferences | See 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
| Hook | Why |
|---|---|
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(...)
| Field | Default | Notes |
|---|---|---|
appVersion, appBuild | required | |
appId, appName, flavor | null | |
mode | BUGCAPSULE_MODE, else kReleaseMode ? production : debug | Debug and QA always record |
enabled | true | Local master switch: no capture code runs when false |
backend | null | BugCapsuleBackendConfig(url:, ingestKey:). Without it: nothing is uploaded and capture settings aren't fetched |
gitSha | BUGCAPSULE_GIT_SHA | gitDirty from BUGCAPSULE_GIT_DIRTY=true |
stateSchemaVersion | 1 | Bump when serialized state changes shape |
checkpointInterval / window / maxEvents | 30 s / 5 min / 20 000 | |
productionSampleRate | 0.1 | |
autoCaptureOnFailure / maxCapsulesPerSession | true / 3 | |
captureNetworkBodies / maxBodyBytes | true / 256 KiB | |
redactKeys, redactHeaders, redactPatterns | [] | redactPatterns: List<RegExp> |
beforeRecord(type, data) | null | Return null (or throw) to drop the event |
beforeCheckpoint(integration, data) | null | Return null to leave that integration out |
outboxLimits | OutboxLimits() 10 / 10 MiB / 7 days | |
maxStaleConfig | 7 days | |
showOverlay | mode ≠ production | |
ignoreError(error) | null | |
labels | {} | Scrubbed like free text |
replayUrl | null | Tests only |
Replay specifics
- iOS simulator: the CLI runs
flutter build ios --simulatoronce, then launches the installed app per attempt and hands over the replay request through a file in the app sandbox (environment variables don't reach the app on recent simulator runtimes). - Other devices:
flutter runwith a dart-define per attempt. Android goes throughadb reverse; this path is implemented but has not been exercised end to end. Physical iOS devices need--lan. - Release builds never enter replay mode (
BUGCAPSULE_REPLAY_URLand request files are ignored whenkReleaseMode). - Pointer replay assumes the same logical screen size; otherwise the CLI scales coordinates and falls back to semantic actions when they exist.
- Idle gaps over 1.5 s shrink to ~0.3 s when both recording and app are idle (running animations get up to 2 s). App
Timers aren't tracked; use--no-compress-idleif replay depends on one.
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.
| Bug | How to trigger | What replay needs |
|---|---|---|
| Profile/recommendations race | Tap Refresh picks until it crashes (~1 in 4) | Recorded response latencies |
| Legacy cart cache | Simulate upgrade from v0.9, restart, open cart | Storage from the launch checkpoint |
| Negative total | Open cart until subtotal < $100, apply SAVE100 | Typed text + server cart + custom assertion |
| Discontinued item | Open cart until "unavailable" shows, checkout | The server response with price: null |
| Orders stuck loading | Open My orders until it spins > 4 s | The recorded 202 + watchdog assertion |
Upgrading
From the 12-package layout to 0.4 (one package)
- Replace every
bug_capsule_*dependency with the singlebug_capsuleline, and everypackage:bug_capsule_*/…import withpackage:bug_capsule/bug_capsule.dart. The API is unchanged. - CLI:
dart pub global deactivate bug_capsule_cli, then activatebug_capsule(see CLI install). - The old
packages/bug_capsule_*folders in the SDK repo only hold stale.dart_tooloutput; ignore them.
From 0.1
BugCapsule.resetForTest()returns aFuture; await it in tests.- The upload outbox is bounded; 0.1 files are kept and count toward the limits.
- BugCapsule no longer moves a capsule to
fixedon results from CLI 0.1 (which couldn't tell an errored attempt from a passing one). Update the CLI. - Capsules captured by 0.1 with body capture off were keyed without body hashes; replaying them reports those requests as unrecorded.