tillpulse
PREVIEWFlutter plugin for Android, iOS, macOS, Windows and Linux: error and native crash capture, navigation and network breadcrumbs, performance transactions, and friction signals, delivered through an offline queue. The API is static — every call is TillPulse.something(…).
Install
flutter pub add tillpulseOr add tillpulse: ^0.1.0 under dependencies in pubspec.yaml. Requires Dart 3.6 and Flutter 3.27 or newer.
Initialise
import 'package:flutter/widgets.dart';
import 'package:tillpulse/tillpulse.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await TillPulse.init(TillPulseOptions(
dsn: 'https://<key>@ingest.tilldev.dev/<project-id>',
release: '1.4.0',
environment: 'production',
beforeSend: (event) async {
if (event.exception?.value.contains('PaymentCancelled') ?? false) return null;
return event;
},
));
TillPulse.runApp(const MyApp());
}init hooks FlutterError.onError, PlatformDispatcher.instance.onError and the isolate error listener. TillPulse.runApp adds a guarded zone for async errors that escape the framework. If your app already runs its own zone, pass the handler instead:
runZonedGuarded(() => runApp(const MyApp()), TillPulse.captureZonedError);Options
| Option | Default | Effect |
|---|---|---|
dsn | required | Project DSN from the dashboard. |
release | required | App version; matches uploaded symbols to events. |
environment | 'production' | Filterable in the dashboard. |
dist | — | Build number within a release. |
beforeSend | — | Rewrite an event, or return null to drop it. |
initialTags | {} | Tags on every event. |
enableNetworkTracking | true | False makes the HTTP client and Dio interceptor pass-throughs. |
networkUrlDenylist | [] | URL substrings never recorded. The DSN host is always excluded. |
enableFrictionDetection | true | Rage-tap and dead-zone detection. |
rageTapThreshold | 3 | Taps in one spot that count as a rage tap. |
deadZoneResponseMs | 300 | A tap with no visible response inside this window is a dead zone. |
enablePreCrashWatcher | true | Snapshots device state when memory, thermal and battery signals converge. |
enableSessionTracking | true | Session start/end markers for crash-free rates. |
maxDailyDataKb | unlimited | Daily cap on queued data. Past it only errors, pre-crash snapshots and session markers are kept. |
disableDefaultPiiScrubbing | false | Turns off on-device PII scrubbing. Only if you scrub in beforeSend. |
enableThreatDetection | false | Root/jailbreak and integrity checks. |
enableInstalledAppsScan | false | Android: lists apps holding the overlay permission. Needs user consent. |
flushIntervalMs | 30000 | How often the queue drains. |
debug | false | Logs SDK activity to the console. |
Users, tags and extras
TillPulse.setUser(id: hashedUserId, segment: 'pro');
TillPulse.setTag('region', 'eu-west');
TillPulse.setTags({'flavor': 'play-store', 'ab': 'checkout-v2'});
TillPulse.setExtra('feature_flags', {'newCheckout': true});
// On sign-out:
TillPulse.clearContext();
TillPulse.resetSession();Send a stable hashed id, not an email or phone number.
Manual capture
try {
await processPayment();
} catch (e, st) {
TillPulse.captureError(e, stackTrace: st);
}
TillPulse.captureMessage('Payment validation failed', severity: Severity.medium);Severities: critical, high (the default for errors), medium, low, info (the default for messages).
Network breadcrumbs
// package:http
final client = TillPulseHttpClient(denylist: ['/health']);
final res = await client.get(Uri.parse('https://api.example.com/cart'));
// dio
dio.interceptors.add(TillPulseDioInterceptor());Each request records method, status and duration. Query strings are stripped before the URL is stored.
Friction signals
GestureDetector(
onTapUp: (d) {
recordTap(d.globalPosition.dx, d.globalPosition.dy, targetLabel: 'BuyButton');
onPressed();
recordResponse(); // something visible happened
},
child: buyButton,
)
// A form left part-way: name, fields completed, total fields.
recordFormAbandon('checkout_address', 3, 8);Performance transactions
await TillPulse.withTransaction(
name: 'checkout',
op: 'flow',
body: (tx) async {
final span = tx.startSpan(op: 'http', description: 'POST /charge');
await charge();
span.finish();
},
);
// Manual control:
final tx = TillPulse.startTransaction(name: 'image-upload', sampleRate: 0.2);
try {
await upload();
tx.finish();
} catch (_) {
tx.finish(status: 'error');
rethrow;
}withTransaction finishes with error when the body throws. sampleRate (0–1) keeps that share of transactions. App start and frame metrics are recorded automatically.
Native crash handlers
A native crash is written to disk on the crashing run and sent as a normal event on the next launch.
| Platform | Mechanism |
|---|---|
| Android | Thread.setDefaultUncaughtExceptionHandler |
| iOS | NSSetUncaughtExceptionHandler |
| macOS | NSSetUncaughtExceptionHandler + signal handlers (SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE) |
| Windows | SetUnhandledExceptionFilter, with a minidump |
| Linux | Signal handlers; link the runner with -rdynamic for frame names |
A session that ends without a clean shutdown is reported on the next launch as an unclean termination: an OS kill, out-of-memory or force-quit.
Offline queue and delivery
Events are scrubbed, then encrypted and persisted in shared_preferences (up to 500 items or 5 MB, oldest low-priority first, for up to 7 days) and survive restarts. Errors are sent before anything else. Failed sends back off exponentially for up to 6 attempts and honour Retry-After; 5 consecutive failures pause delivery for 1–10 minutes. Before an event is stored, any value held in your TillSecrets vault is replaced with [REDACTED:vault-secret]; see Leaked values. Your workspace's data policy also masks personal data again, by default, plus any patterns you add.
The stored queue is sealed with AES-256-GCM. On Android the data key is wrapped by an Android Keystore key that can't be exported (in StrongBox when the device has one); on iOS and macOS the key is a keychain item that never syncs or leaves the device; on Windows the queue is protected with DPAPI for the signed-in user. Encryption runs off the UI thread. An edited or swapped queue fails authentication and is discarded instead of sent, and a queue written by an older version is read once and rewritten encrypted. Linux has no system key store the plugin can rely on, so there, and anywhere the key store can't be reached, nothing is written to disk: events stay in memory until they are sent.
await TillPulse.flush(); // before a known-disruptive operation
await TillPulse.close(); // tests onlyPlatform setup
Android minSdk 21; iOS 12.0.
If you use R8 / ProGuard, keep the plugin and line numbers:
-keep class io.tillpulse.** { *; }
-keepattributes SourceFile,LineNumberTableUpload mapping.txt and dSYMs for each release; see Source maps. The device and connectivity plugins the SDK depends on may need entries in AndroidManifest.xml and Info.plist; see each plugin's pub.dev page.
Troubleshooting
- Events not arriving → set
debug: trueand watch the console. - Obfuscated Android frames → upload
mapping.txtfor that release. - Raw addresses in iOS or macOS native frames → upload the dSYMs of that exact build, e.g.
tilldev pulse dsym upload <id> --release <v> --file build/ios/archive/Runner.xcarchive(see dSYMs). Crashes that arrived before the upload stay as reported. - Errors thrown in
compute()isolates are captured by the isolate error listener.