bugwatch
BugWatch Flutter SDK — crash, error, and log observability for Flutter apps. Capture unhandled and handled exceptions, log messages, breadcrumbs, and user context, and ship them to newinstance.cloud.
How it works (architecture)
The Dart BugWatch API is a thin bridge. Every method call — init, capture,
scope, flush — is forwarded across a MethodChannel (channel name: bugwatch)
to a native Flutter plugin, which delegates to the real native BugWatch SDK:
- iOS: the
BugWatchCocoaPod (bug-watch-ios) - Android: the
cloud.newinstance:bugwatchMaven artifact (bug-watch-android)
The native SDKs own everything below the Dart layer: delivery over HTTPS (with batching, retry, and persistent queuing), signal-based native crash capture, ANR/app-hang detection, release-health session tracking, device metadata collection, and automatic breadcrumbs. The Dart layer translates your Dart exceptions and messages into the format the native SDKs expect, then hands off.
An EventChannel (bugwatch/events) streams native diagnostic lines back to
Dart when debug: true, accessible via BugWatch.instance.onDiagnostic.
Requirements
| Requirement | Minimum |
|---|---|
| Dart SDK | ^3.11 |
| Flutter | ≥ 3.3 |
| iOS deployment target | 14.0 |
| Android minSdkVersion | 24 |
| Android JDK | 17 |
Installation
1. Add the Dart package
Add the package from pub.dev:
# pubspec.yaml
dependencies:
bugwatch: ^0.1.2
For local development, use a path dependency instead:
dependencies:
bugwatch:
path: ../bug-watch-flutter
Then run:
flutter pub get
2. Android native setup
The Flutter plugin depends on cloud.newinstance:bugwatch:0.1.3. The plugin's
android/build.gradle.kts uses Maven Central by default, with mavenLocal()
kept as a development fallback.
The cloud.newinstance:bugwatch:0.1.3 artifact is published to Maven Central.
Add mavenCentral() to your app's android/build.gradle repositories block if
it is not already present (it usually is by default in new Flutter projects):
// android/build.gradle (or settings.gradle dependencyResolutionManagement block)
repositories {
google()
mavenCentral()
}
No other Gradle changes are needed. Flutter autolink registers the plugin.
For local native SDK development, publish from bug-watch-android first:
./gradlew :sdk:publishToMavenLocal
3. iOS native setup (required manual step)
The plugin's podspec declares s.dependency 'BugWatch', '~> 0.1.0', referring
to the native BugWatch iOS SDK pod.
Until BugWatch is published to the CocoaPods trunk spec repo, you must
add a local path override to your app's ios/Podfile above the
flutter_install_all_ios_pods call:
# ios/Podfile
target 'Runner' do
use_frameworks!
use_modular_headers!
# Local path to the native BugWatch iOS SDK.
# Adjust the relative path to match your checkout layout.
pod 'BugWatch', :path => '../../bug-watch-ios'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
Then run:
cd ios && pod install
Known issue — pod name case collision:
CocoaPods is case-sensitive in some contexts. The plugin's podspec is named
bugwatch (all lowercase), while the native dependency is BugWatch
(TitleCase). If pod install fails with a case-related conflict, try cleaning
the Pods cache:
cd ios
pod deintegrate
pod cache clean --all
pod install
The iOS deployment target in ios/Podfile must be 14.0 or higher:
platform :ios, '14.0'
Getting your project credentials
Log in to your merchant dashboard at newinstance.cloud, open the BugWatch section, create or select a project, then go to Settings → API Keys.
- Project ID — starts with
bwp_, e.g.bwp_a1b2c3d4 - App secret — a long random string
The app secret is used only on-device to sign a short-lived ingest token (HMAC-SHA256) before each batch is delivered. The secret itself is never transmitted to the server.
Initialisation
Call BugWatch.instance.init() once in main(), after
WidgetsFlutterBinding.ensureInitialized() and before runApp().
import 'package:bugwatch/bugwatch.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await BugWatch.instance.init(const BugWatchOptions(
projectId: 'bwp_<your-project-id>',
appSecret: '<your-app-secret>',
environment: 'production',
release: '1.4.2+318', // semver or build string — your choice
));
runApp(const MyApp());
}
init() is idempotent — subsequent calls before close() return immediately
without reconfiguring.
All configuration options
BugWatchOptions(
// Required
projectId: 'bwp_…', // BugWatch project public id
appSecret: '…', // Signing secret (never transmitted)
// Strongly recommended
environment: 'production', // Informational tag
release: '1.4.2+318', // Release / build identifier
// Optional — defaults shown
endpoint: 'https://api.newinstance.cloud', // Override only for self-hosted / dev
enabled: true, // Master switch; false = no collection, no delivery
debug: false, // Emit internal diagnostics to BugWatch.instance.onDiagnostic
sampleRate: 1.0, // 0.0–1.0 fraction of events to keep
maxQueueSize: 1000, // Max pending events before oldest are dropped
batchSize: 50, // Events per NDJSON ingest request
flushIntervalMs: 5000, // Auto-flush cadence in ms (0 = timer disabled)
requestTimeoutMs: 15000, // Per-request network timeout in ms
autoCaptureErrors: true, // Install global error handlers automatically (see below)
sensitiveFields: [...], // Keys whose values are redacted (case-insensitive)
retry: RetryPolicy( // Retry policy for failed ingest requests
maxRetries: 3,
baseDelayMs: 500,
maxDelayMs: 10000,
),
)
sensitiveFields defaults cover common credential keys:
password, passwd, pwd, token, accesstoken, refreshtoken, idtoken, authorization, auth, cookie, setcookie, secret, clientsecret, apikey, privatekey, sessionid, ssn, creditcard, cardnumber, cvv, pin, nin, bvn.
Automatic error capture (implemented)
When autoCaptureErrors: true (the default), init() installs two global
error handlers automatically:
FlutterError.onError— catches errors reported by the Flutter framework: widget build failures, image-load errors, rendering exceptions, etc.PlatformDispatcher.instance.onError— catches uncaught async errors that escape the Flutter framework's own zone.
Both handlers chain any previously-installed callback, so any handler your
app or another SDK installed before BugWatch.instance.init() keeps running.
The originals are restored when BugWatch.instance.close() is called.
With autoCaptureErrors: true you do not need try/catch or
runZonedGuarded for most unhandled errors — they are captured automatically.
Set autoCaptureErrors: false if you need full manual control.
runZonedGuarded helper
For errors that escape both Flutter framework zones and PlatformDispatcher,
wrap your runApp() call with the provided helper:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await BugWatch.instance.init(const BugWatchOptions(
projectId: 'bwp_…',
appSecret: '…',
));
await BugWatch.runZonedGuarded(() async {
runApp(const MyApp());
});
}
BugWatch.runZonedGuarded wraps dart:async's runZonedGuarded and forwards
any zone-escaping error to captureException. It can be combined with
autoCaptureErrors: true for maximum coverage.
Capturing events manually
Handled exceptions
try {
await doRiskyThing();
} catch (error, stack) {
await BugWatch.instance.captureException(error, stack);
}
captureException parses the Dart StackTrace into structured frames (filename,
function, line, column) and forwards them — along with the error runtime type and
message — to the native SDK via captureWrapperException(..., platform: "flutter").
The platform: "flutter" tag tells the BugWatch worker to route the frames to
Dart-frame handling rather than the dSYM / R8 symbolicators.
Log messages
await BugWatch.instance.captureMessage('Checkout started', level: Severity.info);
Severity values and their wire integers:
| Constant | Value |
|---|---|
Severity.trace |
10 |
Severity.debug |
20 |
Severity.info |
30 |
Severity.warn |
40 |
Severity.error |
50 |
Severity.fatal |
60 |
Scope
Scope is sticky — it is attached to every subsequent event until changed or cleared.
User identity
// Set user
await BugWatch.instance.setUser(const BugWatchUser(
id: 'u_123',
email: 'ada@example.com',
username: 'ada',
// ip: '…', // optional
));
// Clear user (e.g. on logout)
await BugWatch.instance.setUser(null);
Tags and context
await BugWatch.instance.setTag('screen', 'checkout');
await BugWatch.instance.setContext('cart_id', 'c_987');
Release
await BugWatch.instance.setRelease('1.4.3+319');
Prefer setting release in BugWatchOptions at init time. Call setRelease
only if the release identifier changes at runtime (unusual).
Breadcrumbs
The SDK retains a bounded history of breadcrumbs and attaches them to every outgoing event so you can see what led up to a problem.
await BugWatch.instance.addBreadcrumb(Breadcrumb(
category: 'navigation',
message: 'Opened checkout',
level: Severity.info,
// type: 'default', // optional, defaults to 'default'
// data: {'key': 'val'} // optional string key/value map
// timestamp: DateTime.now() // optional, defaults to now
));
Native crash capture, ANR detection, and session tracking
These features are fully implemented in the underlying native SDKs and work automatically once the Flutter plugin is initialized:
- Native crash capture: on iOS, POSIX signal handlers (
SIGSEGV,SIGABRT,SIGBUS,SIGILL,SIGFPE,SIGTRAP,SIGSYS) plus anNSUncaughtExceptionHandler, with binary-image collection for dSYM symbolication; on Android: JVM uncaught exception handler + NDK C++ signal handler (4 ABIs) for ProGuard/R8 and ELF symbolication. Both platforms run their handlers on a dedicated alternate signal stack, which is what makes a stack overflow reportable, and both chain whatever handler was installed before them so Crashlytics and the OS crash report still work. - ANR / app-hang detection — on Android: watchdog thread fires at 5 000 ms;
on iOS: app-hang tracking fires at 2 000 ms (both configurable via
BugWatchOptionsfields passed from the native option objects, not currently exposed inBugWatchOptionsDart API — native defaults apply). - Release-health sessions — the native SDK tracks session start / end / crash state and reports to the ingest API automatically.
You do not need to call any Dart method to enable these.
What is captured, and at what level
| Crash or event class | Caught by | Level |
|---|---|---|
| Uncaught Dart / Flutter error | FlutterError.onError, PlatformDispatcher.onError |
error |
| Uncaught JVM exception (Android) | Native JVM handler | fatal |
Uncaught NSException (iOS) |
Native NSUncaughtExceptionHandler |
fatal |
| Native signal crash, either platform | Native signal handlers | fatal |
| Crash in the Flutter engine or a native plugin | Native signal handlers | fatal |
| Stack overflow | Native handlers, on the alternate signal stack | fatal |
| ANR (Android) / app hang (iOS) | Native watchdogs | error |
Dart errors are recorded at error rather than fatal on purpose. Unlike React
Native, Flutter does not terminate the process on an uncaught Dart error, and
neither FlutterError.onError nor PlatformDispatcher.onError carries a
fatal flag. A failure that genuinely kills a Flutter app is a native crash, and
the native handlers record that as fatal.
What is never captured
Platform limits, not SDK limits. No crash reporter on either platform captures these.
| Not captured | Why |
|---|---|
| Out-of-memory kills, jetsam, watchdog terminations | The OS sends SIGKILL, which no handler can intercept. The prior session is finalised exited, so crash-free rate reads slightly optimistic. There is no abnormal session status |
| The user force-quitting the app | Indistinguishable from a clean exit |
Anything before BugWatch.init completes |
Handlers are armed during init. Await it as early as possible in main() |
| A crash inside the crash handler itself | The signal is already being handled and the process dies |
| A stack overflow on a thread other than the one that started the SDK | sigaltstack is registered per-thread |
Delivery needs a relaunch
Nothing is uploaded from inside a crash handler: the process is dying and networking is not safe there. The handler writes a small artifact to disk synchronously, and the next launch turns it into a fatal event and uploads it. There is no background job that uploads after the process is gone, so a user who crashes and never returns is never counted. Queued events are delayed, never lost.
Device context on every event
Collected automatically by the native SDK, with nothing to configure: device model, manufacturer and brand (Android), OS name and version, locale, timezone, whether it is a simulator or emulator, app version and build, and the package or bundle identifier. Alongside it every event carries an install id (stable per install), a session id, the release, the environment, your tags, contexts, user and the breadcrumb ring.
Symbol upload and obfuscated stack traces
Dart obfuscation (--split-debug-info / --obfuscate)
When you build with --obfuscate --split-debug-info=<dir>, Dart strips symbol
names from the binary. The Flutter SDK detects this automatically (by checking
for isolate-instruction address markers in the raw stack string) and ships the
raw address-form trace as a rawStack field, which the BugWatch worker can
resolve using flutter symbolize:
flutter symbolize \
--debug-info=<dir>/app.android-arm64.symbols \
--input=<raw-stack.txt>
Upload the --split-debug-info output to BugWatch so the server can resolve Dart
stacks automatically. Dart symbols are a text artifact, not a debug-symbol
archive, so use artifacts upload:
npx @newinstance/bugwatch-cli artifacts upload build/symbols.zip \
--token <keyId>:<secret> \
--platform flutter \
--type dart-symbols \
--release 1.4.2+318
Native crash symbols
Native crash frames (iOS, Android NDK) go through the respective native symbol pipelines:
- iOS dSYM — upload the
.dSYMbundle produced by Xcode withnpx @newinstance/bugwatch-cli symbols upload --platform ios …. - Android native
.so(NDK/ELF) — zip the.sotree first, thennpx @newinstance/bugwatch-cli symbols upload native-symbols.zip --platform android …(bare.sofiles and directories are rejected). - Android R8/ProGuard mapping — a text file, so it uses the artifacts command:
npx @newinstance/bugwatch-cli artifacts upload mapping.txt --platform android --type r8 --release <v>.
Lifecycle
// Drain the pending queue through the native delivery worker.
await BugWatch.instance.flush();
// Tear down the native SDK instance and restore saved error handlers.
await BugWatch.instance.close();
Call flush() before the app exits (e.g. in a dispose or shutdown hook) to
ensure in-flight events are delivered.
Diagnostics
When debug: true, the native SDK emits internal log lines. Listen to them
from Dart:
BugWatch.instance.onDiagnostic.listen((event) {
debugPrint('[BugWatch] ${event['event']}: ${event['data']}');
});
Complete examples
Minimal
import 'package:bugwatch/bugwatch.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await BugWatch.instance.init(const BugWatchOptions(
projectId: 'bwp_a1b2c3d4',
appSecret: 'your_app_secret_here',
environment: 'production',
release: '1.0.0+1',
));
runApp(const MyApp());
}
autoCaptureErrors defaults to true, so unhandled Flutter and Dart errors
are captured automatically without any additional code.
Realistic (zone guard + user + scope + breadcrumbs)
import 'package:bugwatch/bugwatch.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await BugWatch.instance.init(const BugWatchOptions(
projectId: 'bwp_a1b2c3d4',
appSecret: 'your_app_secret_here',
environment: 'production',
release: '1.4.2+318',
debug: false,
sampleRate: 1.0,
));
// Optional: wrap runApp in a zone guard for maximum error coverage.
await BugWatch.runZonedGuarded(() async {
runApp(const MyApp());
});
}
// ─── Somewhere in your app ───────────────────────────────────────────
Future<void> onUserLoggedIn(User user) async {
await BugWatch.instance.setUser(BugWatchUser(
id: user.id,
email: user.email,
username: user.displayName,
));
}
Future<void> onUserLoggedOut() async {
await BugWatch.instance.setUser(null);
}
Future<void> navigateTo(String screen) async {
await BugWatch.instance.addBreadcrumb(Breadcrumb(
category: 'navigation',
message: 'Navigated to $screen',
level: Severity.info,
));
await BugWatch.instance.setTag('screen', screen);
}
Future<void> processOrder(String orderId) async {
try {
await BugWatch.instance.setContext('order_id', orderId);
await submitOrder(orderId);
await BugWatch.instance.captureMessage(
'Order submitted: $orderId',
level: Severity.info,
);
} catch (error, stack) {
await BugWatch.instance.captureException(error, stack);
rethrow;
}
}
Production checklist
projectIdandappSecretare set from build-time environment variables or a secrets manager — not hardcoded in source.environmentis'production'in your production build; use a different value (e.g.'staging') for pre-production.releasematches the version string your team uses to track deployments (e.g.'1.4.2+318').debug: falsein production builds.sampleRate: 1.0unless you intentionally want partial coverage.- Android:
cloud.newinstance:bugwatch:0.1.3resolves from Maven Central or frommavenLocalduring local native SDK development. - iOS:
pod installran successfully and theBugWatchpod resolves. - iOS deployment target is
14.0or higher inios/Podfile. - For obfuscated builds (
--split-debug-info/--obfuscate): Dart symbol files are uploaded to BugWatch after each release. - For native crash symbolication: dSYM (iOS) and mapping.txt (Android) are uploaded to BugWatch after each release.
Known limitations
Stated plainly so you can plan around them rather than discover them during an incident.
- Delivery needs a relaunch. Crash reports upload the next time the app starts. There is no background upload job on either platform, so a user who crashes and never returns is never counted.
- Out-of-memory kills are invisible.
SIGKILLcannot be caught, so those runs finalise asexitedand crash-free rate reads slightly better than reality. - Dart errors are never
fatal. See the level table above; this is correct for Flutter, but it means a Dart error and a native crash are not comparable by level alone. - ANR and hang thresholds are not exposed in the Dart API. The native defaults apply (5000 ms on Android, 2000 ms on iOS).
- Stack overflow is covered only on the thread that started the SDK.
sigaltstackis registered per-thread. - A Flutter crash needs two sets of symbols. Dart frames need the
--split-debug-infooutput; native frames need dSYMs on iOS and the R8 mapping plus ELF symbols on Android. Uploading only one leaves half the stack unreadable. - No automatic screenshot, widget tree, or profiling capture. The SDK collects crashes, errors, logs, breadcrumbs, device context and release-health sessions only.
Troubleshooting
Pod install fails: Unable to find a specification for 'BugWatch'
The BugWatch pod is not available from your configured CocoaPods spec repos.
Add a local path override in ios/Podfile:
pod 'BugWatch', :path => '../../bug-watch-ios'
Pod install fails with a case-collision error
The plugin's podspec is bugwatch (lowercase); the native pod is BugWatch
(TitleCase). CocoaPods can treat them as the same spec and refuse. Clean and
retry:
cd ios
pod deintegrate
pod cache clean --all
pod install
Android build fails: Could not find cloud.newinstance:bugwatch:0.1.3
Make sure mavenCentral() is present in your Android repositories. If you are
testing a local native SDK build, run ./gradlew :sdk:publishToMavenLocal in
the bug-watch-android directory first.
Events are not appearing in the BugWatch dashboard
- Set
debug: trueand listen toBugWatch.instance.onDiagnosticto see internal SDK logs. - Confirm
enabled: true(the default). - Confirm
projectIdandappSecretare correct (from the dashboard). - Verify network connectivity — the SDK delivers to
https://api.newinstance.cloud/api/v1/bugwatch/ingest/mobile. - Call
await BugWatch.instance.flush()to force immediate delivery (the auto-flush timer runs every 5 000 ms by default).
BugWatch.instance.init() throws a PlatformException
The native plugin could not initialize. Common causes:
- Android:
cloud.newinstance:bugwatchwas not found (see above). - iOS:
BugWatchpod was not installed or the Podfile path is wrong. projectIdorappSecretis empty.
Dart stack traces are not symbolicated in the dashboard
For obfuscated release builds (built with --obfuscate --split-debug-info),
you must upload the Dart symbol file to BugWatch using the CLI. Without the
upload, the worker receives raw addresses it cannot resolve.
Upgrading
0.1.0 → 0.1.1
autoCaptureErrorsoption added (defaulttrue) — no code change needed unless you want to opt out.BugWatch.runZonedGuardedstatic helper added — optional.- Android native dep bumped to
cloud.newinstance:bugwatch:0.1.1(adds NDK native-crash capture).
Links
- BugWatch dashboard
- REST API reference / Swagger
- BugWatch CLI (
@newinstance/bugwatch-cli) - BugWatch iOS SDK (
bug-watch-ios) - BugWatch Android SDK (
bug-watch-android)
License
MIT — see LICENSE.
Libraries
- bugwatch
- BugWatch — crash, error, and log observability for Flutter.