pulse_ops
A modern, Flutter-native developer toolkit for in-app network inspection, performance & memory monitoring, and crash diagnostics โ built for iOS & Android, with a beautiful dark Material 3 UI.
PulseOps ships with four focused capabilities:
- ๐ Network Inspector โ a Dio interceptor that records every request, pretty-prints JSON, exports cURL, retries calls, and presents it all in a developer-grade dark inspector.
- โก Performance Monitoring โ real-time FPS tracking, frame drop & jank detection, startup time measurement, and API latency charts.
- ๐ง Memory Monitoring โ RSS memory tracking, spike detection, leak
detection via
FlutterMemoryAllocations, widget lifecycle logs, and rebuild frequency tracking. - ๐ฅ Crash Diagnostics โ pluggable bridge to Firebase Crashlytics (or any backend) with rich breadcrumbs and automatic attachment of recent API activity to every crash report.
โจ Highlights
- ๐จ Beautiful dark, Material 3 inspector with monospace JSON viewer and syntax highlighting
- ๐ One-line Dio integration โ works with
GET,POST,PUT,PATCH,DELETE, andmultipart/form-data - ๐ Search, filter by method / status family / slow / failed โ live and composable
- ๐ Copy buttons everywhere โ headers, body, full cURL
- โป Retry requests from the inspector with your real Dio client
- โก Real-time FPS monitor โ sparkline chart, dropped-frame list, startup time, and API latency bar chart
- ๐ง Memory monitor โ RSS sparkline, spike warnings, leak list, widget lifecycle breakdown, and rebuild frequency โ all live
- ๐พ Persistent network store โ
FileBackedNetworkStoresurvives app restarts with zero extra setup - ๐ก Unified event exporter โ one interface to forward every failed request and every crash to your own analytics backend
- ๐ณ Shake to open โ shake the device to launch the inspector
- ๐ Sanitization for secrets / tokens / passwords before storage or upload
- ๐งญ Breadcrumb trail with bounded ring buffer
- ๐ฅ Backend-agnostic crash reporter โ wire Crashlytics, Sentry, or your
own logger via a thin
PulseCrashReporterinterface - ๐ก๏ธ Production-safe โ disabled in release builds by default
- ๐ชถ Lightweight โ no Firebase or Isar at runtime; pure Dart + Dio + Riverpod core
๐ Quick start
1. Add the dependency
dependencies:
pulse_ops: ^1.3.0
dio: ^5.4.0
2. Initialize in main()
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:pulse_ops/pulse_ops.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await PulseOps.initialize(
crashlytics: true,
enableInRelease: false,
sanitizeKeys: ['token', 'password', 'authorization'],
);
final dio = Dio()..interceptors.add(PulseOps.instance.dioInterceptor);
runApp(PulseOps.instance.wrap(retryDio: dio, child: const MyApp()));
}
That's it. A draggable floating button appears in debug builds; tap it to open the inspector. Use the ๐ง toolbar button for memory, โก for performance.
3. (Optional) Open the inspector imperatively
PulseOps.instance.openInspector(context, retryDio: dio);
๐ Network Inspector
Every Dio call routed through PulseOps.instance.dioInterceptor is captured
as a NetworkRecord and pushed into an in-memory ring buffer (configurable
via PulseOpsConfig.maxRecords).
The inspector UI provides:
| Surface | Contents |
|---|---|
| Timeline list | Newest-first list of requests with method chip, host, path, timestamp, duration, status chip |
| Overview tab | Status, timing, request/response sizes, error details |
| Headers tab | Sanitized request + response headers with copy-all |
| Request tab | Query params and request body with syntax-highlighted JSON |
| Response tab | Highlighted response body and error banner |
| cURL tab | One-tap copy of the full curl command |
The list supports:
- ๐ Live search across URL, method, status, host, and error message
- ๐ฏ Filter by HTTP method (
GET/POST/PUT/PATCH/DELETE) - โ ๏ธ Failed only toggle โ show only errored requests
- ๐ข Slow only toggle โ requests exceeding
slowRequestThresholdMs - ๐ข Status-family chips โ
2xx/3xx/4xx/5xx
Persistent store
To keep network history across app restarts, swap in FileBackedNetworkStore:
final store = FileBackedNetworkStore(maxRecords: 200);
await store.initialize(); // loads previous session from disk
await PulseOps.initialize(
networkStore: store,
);
Retrying a request
Pass your authenticated Dio instance to wrap(retryDio:) or
openInspector(retryDio:). The retry button in the app bar reissues the
captured request via that client.
Multipart support
FormData payloads are described (field names, file names, sizes) rather
than serialized โ useful for inspecting uploads without breaking streams.
โก Performance Monitoring
The performance screen is available from the โก icon in the inspector
toolbar. It requires no additional dependencies โ everything uses Flutter's
built-in WidgetsBinding.addTimingsCallback.
What's tracked
| Metric | Description |
|---|---|
| Startup time | Wall-clock time from PulseOps.initialize() to the first rendered frame |
| Current FPS | Rolling average over the last 15 frames |
| Dropped frames | Frames taking > 16 ms (one refresh period at 60 Hz) |
| Severe jank | Frames taking > 33 ms (two full refresh periods) |
| API latency | Per-request bar chart for the last 40 completed calls |
FPS chart
A gradient-filled sparkline shows FPS over the last N frames (default 300). The line turns green (โฅ 55 fps), yellow (โฅ 40 fps), or red (< 40 fps). Reference grid lines are drawn at 60 fps and 30 fps.
Latency chart
Each bar represents one completed request, coloured:
- ๐ข Green โ within the slow-request threshold
- ๐ก Yellow โ exceeds
slowRequestThresholdMs - ๐ด Red โ the request failed
A dashed threshold line marks the slow boundary.
๐ง Memory Monitoring
The memory screen is available from the ๐ง icon in the inspector toolbar.
It uses dart:io's ProcessInfo.currentRss for RSS sampling and Flutter's
FlutterMemoryAllocations for object lifecycle โ no extra dependencies.
Metrics
| Metric | Description |
|---|---|
| Current RSS | Process resident set size, sampled every 2 s (configurable) |
| Peak RSS | Highest RSS reading since monitoring started |
| Memory spikes | Samples >20% above the 10-sample rolling average |
| Potential leaks | Objects created but not disposed after 30 s |
| Widget lifecycle | Created / active / disposed counts per type |
| Rebuild counts | How many times each widget type has been rebuilt |
Leak detection
PulseOps subscribes to FlutterMemoryAllocations and tracks the lifecycle
of every ChangeNotifier, AnimationController, TextEditingController, and
other disposable Flutter objects. Any object that remains alive for > 30 s
without being disposed appears in the Potential Leaks list with its age.
Rebuild tracking
Call PulseOps.instance.memoryStore.recordRebuild(widgetType) at the top of
your build() method to track rebuild frequency:
@override
Widget build(BuildContext context) {
PulseOps.instance.memoryStore.recordRebuild('MyHeavyWidget');
return ...;
}
Counts appear colour-coded in the Memory screen โ red (> 50), yellow (> 20).
๐ก Unified Event Exporter
Implement PulseEventExporter and pass it to PulseOps.initialize to receive
every failed API call and every crash in one place, so you can forward them
to Mixpanel, Amplitude, your own backend, or any analytics sink:
class MyAnalyticsExporter implements PulseEventExporter {
@override
Future<void> onFailedRequest(NetworkRecord record) async {
await MyAnalytics.track('api_error', {
'url': record.url,
'method': record.method,
'status': record.statusCode,
'error': record.error,
'duration_ms': record.duration.inMilliseconds,
});
}
@override
Future<void> onCrash(
Object error,
StackTrace? stackTrace, {
required String? reason,
required bool fatal,
required List<Breadcrumb> breadcrumbs,
required List<NetworkRecord> recentRequests,
}) async {
await MyAnalytics.trackCrash(
error.toString(),
fatal: fatal,
context: {'breadcrumb_count': breadcrumbs.length},
);
}
}
await PulseOps.initialize(
eventExporter: MyAnalyticsExporter(),
);
This is separate from PulseCrashReporter โ the exporter fires for both
crashes and failed requests, making it ideal for a unified observability
pipeline.
๐ฅ Crash Diagnostics
PulseOps decouples itself from any specific crash backend via the
PulseCrashReporter interface, so the package itself does not depend on
firebase_crashlytics. You wire that up in your app.
Example adapter for Firebase Crashlytics
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:pulse_ops/pulse_ops.dart';
class FirebaseCrashReporterAdapter implements PulseCrashReporter {
FirebaseCrashReporterAdapter(this._c);
final FirebaseCrashlytics _c;
@override
Future<void> recordNonFatal(Object error,
{StackTrace? stackTrace, String? reason, Map<String, dynamic>? context}) async {
await _attach(context);
await _c.recordError(error, stackTrace, reason: reason, fatal: false);
}
@override
Future<void> recordFatal(Object error,
{StackTrace? stackTrace, Map<String, dynamic>? context}) async {
await _attach(context);
await _c.recordError(error, stackTrace, fatal: true);
}
@override
Future<void> attachBreadcrumbs(List<Breadcrumb> breadcrumbs) async {
for (final b in breadcrumbs) {
await _c.log(b.toString());
}
}
@override
Future<void> attachNetworkHistory(List<NetworkRecord> records) async {
final summary = records.take(20).map((r) =>
'${r.method} ${r.endpoint} -> ${r.statusCode ?? r.status.name}').join('\n');
await _c.setCustomKey('pulse_ops_recent_requests', summary);
}
@override
Future<void> setCustomKey(String key, Object value) =>
_c.setCustomKey(key, value);
Future<void> _attach(Map<String, dynamic>? context) async {
if (context == null) return;
for (final e in context.entries) {
await _c.setCustomKey(e.key, e.value.toString());
}
}
}
Then pass it in:
await PulseOps.initialize(
crashReporter: FirebaseCrashReporterAdapter(FirebaseCrashlytics.instance),
);
Example adapter for Sentry
import 'package:pulse_ops/pulse_ops.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class SentryCrashReporterAdapter implements PulseCrashReporter {
const SentryCrashReporterAdapter();
@override
Future<void> recordNonFatal(Object error,
{StackTrace? stackTrace, String? reason, Map<String, dynamic>? context}) async {
await Sentry.captureException(
error,
stackTrace: stackTrace,
hint: Hint.withMap({
if (reason != null) 'reason': reason,
if (context != null) ...context,
}),
);
}
@override
Future<void> recordFatal(Object error,
{StackTrace? stackTrace, Map<String, dynamic>? context}) async {
await Sentry.captureException(
error,
stackTrace: stackTrace,
withScope: (scope) => scope.setTag('fatal', 'true'),
);
}
@override
Future<void> attachBreadcrumbs(List<Breadcrumb> breadcrumbs) async {
for (final b in breadcrumbs) {
await Sentry.addBreadcrumb(
SentryBreadcrumb(
message: b.message,
level: _sentryLevel(b.level),
timestamp: b.timestamp,
data: b.data,
),
);
}
}
@override
Future<void> attachNetworkHistory(List<NetworkRecord> records) async {
final summary = records.take(20).map((r) =>
'${r.method} ${r.endpoint} -> ${r.statusCode ?? r.status.name}').join('\n');
await Sentry.configureScope(
(scope) => scope.setContexts('pulse_ops_recent_requests', {'log': summary}),
);
}
@override
Future<void> setCustomKey(String key, Object value) async {
await Sentry.configureScope((scope) => scope.setTag(key, value.toString()));
}
SentryLevel _sentryLevel(BreadcrumbLevel level) {
switch (level) {
case BreadcrumbLevel.debug: return SentryLevel.debug;
case BreadcrumbLevel.info: return SentryLevel.info;
case BreadcrumbLevel.warning: return SentryLevel.warning;
case BreadcrumbLevel.error: return SentryLevel.error;
}
}
}
Then initialize Sentry first, then PulseOps:
await SentryFlutter.init(
(options) => options.dsn = 'YOUR_DSN',
appRunner: () async {
await PulseOps.initialize(
crashReporter: const SentryCrashReporterAdapter(),
);
runApp(PulseOps.instance.wrap(child: MyApp()));
},
);
What gets attached to crashes
Whenever an error is reported through PulseOps โ automatically for failed
HTTP requests, or manually via PulseOps.instance.recordError(...):
- The breadcrumb trail (default 50 entries) is forwarded.
- The last 20 network records are summarized and attached as context.
- Any additional
extramap you pass is merged in.
Adding your own breadcrumbs
PulseOps.instance.log('User opened checkout', data: {'cart_size': 4});
Reporting errors manually
try {
await doRiskyThing();
} catch (e, st) {
await PulseOps.instance.recordError(e, st, reason: 'checkout pipeline');
}
โ๏ธ Configuration
const PulseOpsConfig(
// โ General โ
enableInRelease: false, // keep disabled in prod builds
showOverlay: true, // floating launcher button
// โ Network โ
maxRecords: 200, // request ring-buffer capacity
sanitizeKeys: ['authorization', ...], // keys redacted before storage
slowRequestThresholdMs: 2000, // ms to flag a request as slow
captureFailedRequestsAsCrashEvents: true,
attachNetworkHistoryToCrashes: true,
// โ Performance โ
enableFpsMonitor: true, // frame-timing subscriber
fpsFrameBufferSize: 300, // frames kept in memory
// โ Memory โ
enableMemoryMonitor: true, // RSS polling + FlutterMemoryAllocations
memorySampleIntervalSeconds: 2, // polling interval
memorySnapshotBufferSize: 120, // snapshots kept (~4 min at 2 s)
// โ Overlay / UX โ
enableShakeToOpen: true, // shake gesture to open inspector
shakeThreshold: 22.0, // m/sยฒ to trigger a shake
inspectorPresentation: InspectorPresentation.bottomSheet, // or .fullScreen
// โ Crash โ
maxBreadcrumbs: 50,
)
You can pass it directly to PulseOps.initialize(config: ...), or use the
shorthand named args enableInRelease, sanitizeKeys, crashlytics.
๐ Architecture
lib/
โโโ pulse_ops.dart # public exports
โโโ src/
โโโ core/ # facade + config + PulseEventExporter
โโโ network/
โ โโโ interceptor/ # PulseDioInterceptor
โ โโโ models/ # NetworkRecord (with toJson/fromJson)
โ โโโ store/ # InMemoryNetworkStore, FileBackedNetworkStore
โ โโโ utils/ # CurlBuilder, Sanitizer, LogExporter
โโโ performance/
โ โโโ frame_metric.dart # FrameMetric value type
โ โโโ performance_store.dart # ring-buffer + stream
โ โโโ fps_tracker.dart # WidgetsBinding timing subscriber
โโโ memory/
โ โโโ memory_snapshot.dart # RSS snapshot value type
โ โโโ tracked_object.dart # object lifecycle record
โ โโโ memory_store.dart # ring-buffer + leak map + rebuild counts
โ โโโ memory_monitor.dart # RSS polling + FlutterMemoryAllocations
โโโ crash/ # breadcrumbs + reporter + bridge
โโโ ui/
โ โโโ inspector/ # screens, tabs, widgets
โ โโโ performance/ # PerformanceScreen + charts
โ โโโ memory/ # MemoryScreen + RSS chart
โ โโโ overlay/ # draggable launcher + shake detector
โ โโโ theme/ # dark Material 3 theme
โโโ providers/ # Riverpod scope
The design follows clean architecture principles: the network layer is plain Dart with no Flutter imports, the UI consumes data only through Riverpod providers, and crash/export backends are injected via interfaces. This makes it trivial to:
- swap the in-memory store for
FileBackedNetworkStoreor any custom sink - substitute the crash reporter for Sentry, Bugsnag, or a custom logger
- forward every event to your own backend via
PulseEventExporter - embed the inspector inside a debug menu without using the overlay
๐งช Testing
The package ships with a full test suite covering the sanitizer, cURL builder, in-memory store, breadcrumb trail, Dio interceptor (success / failure / sanitization paths), and the facade.
flutter test
๐ฃ Roadmap
xMemory monitoring โ RSS, leaks, lifecycle, rebuild tracking (v1.3)xPersistent network store + unified event exporter (v1.3)xReal-time FPS monitor, frame drop detection, API latency chart (v1.2)xShake-to-open, expandable bottom sheet, log export (v1.1)HTTP/2 +httppackage interceptor adapterLog inspector (debugPrint /Logger)Per-host throttling visualizerGC pressure & heap breakdown charts
๐ License
MIT โ see LICENSE.
Libraries
- pulse_ops
- PulseOps โ a modern Flutter-native developer toolkit for in-app network inspection and crash diagnostics.