FlogFlow

FlogFlow

Observability for Flutter β€” Logs + Session Replay in a single SDK.

pub package platforms license: MIT

🌐 flog-flow.com β€” create your account and grab your project's projectToken.

English Β· EspaΓ±ol
Spanish docs live on the website; the package also ships README.es.md.


What is FlogFlow?

FlogFlow gives you two observability tools for your Flutter app from a single dependency. Use them separately or together:

  • Logs β€” structured logs (levels, attributes, stack traces, and even a screenshot of the moment things broke) delivered to your cloud dashboard. Lightweight: it doesn't capture screens or gestures, it only ships the logs you emit.
  • Session Replay β€” records the user's real journey (screens as screenshots, taps, scrolls, navigation, network, logs, and errors) and lets you play it back step by step in a web viewer. It can upload to your cloud dashboard or stay 100% local in an encrypted .flowx file.
   [Screen]  ──"User clicks 'Sign in'"──▢  [Screen]  ──▢  [Screen]
      β€’tap                                    β€’tap          ⚠ error

Install the package, paste your project token (or skip even that, in local mode) and the data starts flowing. No agents, no complicated setup. Everything expensive is opt-in, so nothing affects performance unless you turn it on.


The 3 ways to integrate it

Where does the projectToken come from? Go to flog-flow.com, create your account (with Google) and a project: that's where you get your flw_… token to paste into the SDK. Local mode (C) needs no account and no token.

Pick the one that fits. All of them start by installing the package:

# pubspec.yaml
dependencies:
  flog_flow: ^0.5.0
flutter pub get
import 'package:flog_flow/flog_flow.dart';

A Β· Logs only β€” the simplest way to start

One init at startup and one line per log. Ideal if you just want to see what's happening in production without recording sessions.

await FlogLogs.init(
  projectToken: 'flw_xxxxxxxxxxxxxxxx',   // from your dashboard
  service: 'my-app',
  env: 'prod',
  version: '1.4.2',
);

FlogLogs.info('session started', logname: 'auth', attributes: {'userId': 'u_123'});
FlogLogs.warn('token about to expire', logname: 'auth');
FlogLogs.error('payment declined', logname: 'payments', error: e, stackTrace: st, screenshot: true);

β†’ You see them in the Logs tab of the dashboard: filters by level and logname, search, stack traces, attributes, and screenshots.

Dashboard Logs tab: levels, logname, version and message for each log

B Β· Session Replay in the cloud β€” record and upload with a token

Wrap your MaterialApp and start with FlogCloud.start. Every session uploads itself (on background, on close, or on a crash) and shows up in your dashboard, ready to replay.

MaterialApp(
  navigatorObservers: [FlogFlow.instance.navigatorObserver],   // 1) observe navigation
  builder: (context, child) => FlogFlogScope(child: child!),   // 2) capture taps + screenshots
  // ...your routes / home
);

// 3) a single call: records + uploads automatically
await FlogCloud.start(
  projectToken: 'flw_xxxxxxxxxxxxxxxx',
  encryptionPassphrase: 'PROJECT-PASSPHRASE',   // the file always travels encrypted
  appName: 'MyApp',
  appVersion: '1.4.2',
);

β†’ Sessions appear in the dashboard and open with one click β€” nothing to download.

Session viewer: journey map with screens, numbered taps and navigation

⚠️ Not seeing your backend calls in the session?

Network capture is off by default (privacy and performance) and needs TWO things at once β€” with only one of them, nothing is recorded:

// 1) the interceptor on your Dio client:
dio.interceptors.add(FlogDioInterceptor());

// 2) the toggle in FlogCloud.start's config:
config: const CaptureConfig(captureNetwork: true),

Since 0.4.1 the console warns you in debug if the interceptor sees traffic while the toggle is off. If you use the http package instead of Dio the interceptor doesn't apply: record the key requests with FlogFlow.instance.recordEvent(type: 'network', …).

C Β· Local mode β€” free, no account, no token

Same MaterialApp wiring, but with FlogFlow.instance.start. You export an encrypted .flowx file and open it yourself in the web viewer. Perfect for trying the library out or debugging without signing up for anything.

MaterialApp(
  navigatorObservers: [FlogFlow.instance.navigatorObserver],
  builder: (context, child) => FlogFlogScope(child: child!),
);

await FlogFlow.instance.start(
  appName: 'MyApp',
  config: const CaptureConfig(encryptionPassphrase: 'CHANGE-THIS-PASSPHRASE'),
);

// whenever you want the file:
final file = await FlogFlow.instance.export();   // encrypted .flowx
await FlogFlow.instance.stop();

β†’ Upload the .flowx to the web viewer, type the passphrase, and replay the session. Nothing to install.

Web viewer: drop your .flowx file, type your passphrase and replay the session

The three modes coexist. The most common setup is Logs (A) + Session Replay (B) together, sharing the same project token.


Where each piece goes (avoid the common trip-ups)

These few rules solve almost every integration problem that has been reported to us:

1. Call start AFTER runApp, without blocking startup. FlogCloud.start no longer waits for pending uploads (they drain in the background), but it's still correct to start it after the first frame:

void main() {
  runApp(const MyApp());
  WidgetsBinding.instance.addPostFrameCallback((_) {
    FlogCloud.start(
      projectToken: 'flw_…',
      encryptionPassphrase: const String.fromEnvironment('FLOG_KEY'),
      appName: 'MyApp',
      initialRouteName: '/splash',  // name of the first screen
    );
  });
}

2. One observer PER Navigator. Flutter doesn't allow sharing a NavigatorObserver between two Navigators (it fails with a framework assertion that never mentions flog_flow). If your app has more than one β€” the typical case: a MaterialApp nested inside a GetMaterialApp, or your own Navigators β€” create a new one for each:

GetMaterialApp(navigatorObservers: [FlogFlow.instance.newNavigatorObserver()]);
// and in the inner MaterialApp:
MaterialApp(navigatorObservers: [FlogFlow.instance.newNavigatorObserver()]);

With a single Navigator, FlogFlow.instance.navigatorObserver is still valid. Watch out with GetX: Get.context points at the GetMaterialApp's navigator, not at the MaterialApp you may have underneath.

3. Exactly one FlogFlogScope in the whole tree (the outermost one, normally in the root app's builder:). Two nested scopes shadow each other: screenshots come from the wrong RepaintBoundary with no error at all. Since 0.4.0 the console warns you in debug if you mount more than one.

4. Don't compile the passphrase into your code. That key decrypts the sessions β€” and with replay in production, sessions are screenshots of real users. Pass it through the environment and fetch it from your backend or remote config in release builds:

flutter run --dart-define=FLOG_KEY=a-long-secret-passphrase
encryptionPassphrase: const String.fromEnvironment('FLOG_KEY'),

Unnamed routes (anonymous MaterialPageRoutes): the graph comes out without screen names. Give them names with RouteSettings(name: …) or define a global resolver:

FlogFlow.instance.screenNameResolver =
    (route) => route.settings.name ?? route.runtimeType.toString();

5. Network is NOT captured automatically (it's opt-in for privacy and performance). You need both things β€” the interceptor on your Dio client AND the toggle in the config:

dio.interceptors.add(FlogDioInterceptor());

FlogCloud.start(
  // …,
  config: const CaptureConfig(captureNetwork: true),
);

With only one of the two, nothing is recorded (since 0.4.1 the console warns in debug if the interceptor sees traffic while the toggle is off). If your app uses the http package instead of Dio the interceptor doesn't apply: record the key requests by hand with FlogFlow.instance.recordEvent(type: 'network', …).

6. Logs ↔ session link themselves… if both are running. When FlogLogs and the recorder (FlogCloud.start) are both active in the same app, every log carries the id of the session in progress and the dashboard lets you jump from a log to its session and from a session to its logs. If you only use FlogLogs (no replay), the logs still arrive β€” there's just no session to link them to.



Detailed reference

Platforms

The recorder uses dart:io + path_provider, so it runs on Android, iOS, macOS, Windows, and Linux. Flutter web is not a recording target (playback happens in the published web viewer, separately).


Session Replay quickstart (3 steps)

import 'package:flog_flow/flog_flow.dart';

MaterialApp(
  // 1) observe navigation
  navigatorObservers: [FlogFlow.instance.navigatorObserver],
  // 2) wrap the app to capture taps + screenshots
  builder: (context, child) => FlogFlogScope(child: child!),
  // ...your routes / home
);

// 3) control the recording (exports are always encrypted β†’ set a passphrase)
await FlogFlow.instance.start(
  appName: 'MyApp',
  config: const CaptureConfig(encryptionPassphrase: 'CHANGE-THIS-PASSPHRASE'),
);

// (optional) label the action that triggers a transition, before navigating:
FlogFlow.instance.tag('User clicks "Sign in"');
Navigator.pushNamed(context, '/password');

// whenever you want the encrypted file (.flowx):
final file = await FlogFlow.instance.export();
await FlogFlow.instance.stop();

It works the same with go_router (pass FlogFlow.instance.navigatorObserver to GoRouter(observers: [...])) and with GetX (GetMaterialApp forwards navigatorObservers). If you don't call tag(...), the arrow uses the route name.


Full integration (example)

Future<void> main() async {
  runZonedGuarded(() async {
    WidgetsFlutterBinding.ensureInitialized();

    // capture print/debugPrint (gated by captureLogs)
    FlogLogCapture.installDebugPrintCapture();

    runApp(const MyApp());

    // start recording after the first frame
    WidgetsBinding.instance.addPostFrameCallback((_) {
      FlogFlow.instance.start(
        appName: 'MyApp',
        appVersion: '1.4.2',
        build: '142',
        userId: 'u_123',                 // optional; careful with PII
        config: const CaptureConfig(
          captureNetwork: true,          // records HTTP (needs the interceptor)
          captureLogs: true,             // records print/debugPrint
          identifyTapTargets: true,      // labels the tapped widget
          encryptionPassphrase: 'CHANGE-THIS-PASSPHRASE', // encrypted .flowx export
        ),
      );
    });
  }, (error, stack) {
    FlogFlow.instance.recordError(error, stackTrace: stack, context: 'global zone');
  },
    zoneSpecification: FlogLogCapture.zoneSpec(),   // captures raw print()
  );
}

// on your Dio client:
dio.interceptors.add(FlogDioInterceptor());

// capture widget errors too:
FlutterError.onError = (d) =>
    FlogFlow.instance.recordError(d.exception, stackTrace: d.stack, context: 'widget');

CaptureConfig reference

Anything that could cost you is a toggle. The defaults favour performance and privacy (the expensive things ship turned off).

Option Type Default What it does
captureOnNavigation bool true Takes a screenshot when entering each screen.
captureOnTap bool true Refreshes the screen image after a tap.
recordTaps bool true Records taps/scrolls (if false, navigation only).
recordScroll bool true Records scrolls (as arrows).
captureOnScroll bool false Re-captures the screen on every scroll (heavy; OOM risk).
framePerInteraction bool false Saves one screenshot per tap, frozen at the instant of the interaction (more disk).
scrollSlop double 18.0 Drag distance (dp) used to classify scroll vs tap.
pixelRatio double 0.6 Screenshot scale (↓ = lighter).
jpegQuality int 45 JPEG quality (0–100).
maxLongestSide int 900 Cap on the longest side (px); bounds memory.
minCaptureInterval Duration 350ms Debounce between captures.
navigationSettleDelay Duration 300ms Wait after navigating before capturing (lets the transition finish).
maxScreens int 400 Hard cap on screens per session.
captureNetwork bool false Records HTTP requests (Dio interceptor).
captureNetworkBodies bool false Includes query + body preview (may contain PII).
captureLogs bool false Records print/debugPrint.
recordBreadcrumbs bool true Enables manual breadcrumb()/log().
identifyTapTargets bool false Stores the text of the widget under the tap.
recordOnErrorOnly bool false Only keeps/exports the session if there was an error.
collectSessionMetadata bool true Collects locale, OS version, etc.
redactSensitive bool true Applies RedactedZones before capturing.
encryptionPassphrase String? null Key used to encrypt the export. Required: exports are always encrypted.
deleteAfterExport bool true Deletes on-disk data after exporting.
cleanupOnStart bool true Deletes old sessions on start.

API reference

FlogFlow.instance

Member Description
start({appName, config?, initialRouteName?, appVersion?, build?, userId?, metadata?}) Starts a recording.
stop() Closes the recording and leaves it saved.
export({outFile?, passphrase?, deleteAfterExport?}) β†’ Future<File> Generates the encrypted .flowx file.
exportIfError({...}) β†’ Future<File?> Exports only if there was an error; otherwise discards.
discard() Deletes the current session from disk.
clear() Deletes everything the library has on disk (current + old).
tag(String label) Labels the next navigation transition.
recordError(error, {stackTrace?, context?}) Records an error (message + stack + screenshot).
breadcrumb(message, {data?}) Adds a breadcrumb to the timeline.
log(message, {level}) Records a manual log.
recordEvent({type, message, level?, data?}) Adds a generic event.
enableAutoUpload({upload, onAppClose?, onBackground?, onError?}) Uploads the flow on background / close / crash (only if there's new content).
recoverPendingUploads({upload, passphrase}) β†’ Future<int> Re-sends pending sessions at startup.
currentSizeBytes() β†’ Future<int> On-disk size of the current session.
navigatorObserver The NavigatorObserver to register.
isRecording / hadError Recording state.
onErrorRecorded Callback fired when an error is recorded.

Widgets and helpers

API Description
FlogFlogScope(child:) Wraps the app (captures taps + screenshots).
FlogDioInterceptor() Dio interceptor for recording network activity.
FlogLogCapture.installDebugPrintCapture() Captures debugPrint.
FlogLogCapture.zoneSpec() ZoneSpecification for capturing print().
RedactedZone(child:) Masks sensitive content in screenshots.
FlogHttpUpload(url:, headers?, fields?) Ready-made HTTP upload for enableAutoUpload.

Feature guides

The navigatorObserver detects push/pop/replace and creates one node per screen. Label the action that triggers the navigation with tag(...) before navigating:

FlogFlow.instance.tag('Confirm order');
Navigator.pushNamed(context, '/checkout');

Errors

try {
  await checkout();
} catch (e, st) {
  FlogFlow.instance.recordError(e, stackTrace: st, context: 'checkout');
  rethrow;
}

Stores message + stack trace + a screenshot of the state. In the viewer, screens with an error show a red ⚠ chip and you can read the full stack. It works even with recordTaps turned off.

FlogFlow.instance.breadcrumb('cart updated', data: {'items': 3});
FlogFlow.instance.log('token about to expire', level: 'warn');

For automatic log capture:

FlogLogCapture.installDebugPrintCapture();                 // debugPrint + framework
runZonedGuarded(body, onError, zoneSpecification: FlogLogCapture.zoneSpec()); // print()
// and in the config: captureLogs: true

Network capture (Dio)

dio.interceptors.add(FlogDioInterceptor());
// config: captureNetwork: true  (optionally captureNetworkBodies: true)

Records method host/path β†’ status (ms). By default it stores neither headers nor bodies so it can't leak tokens/PII; captureNetworkBodies adds the query string and a truncated body preview.

Record only when there's an error

CaptureConfig(recordOnErrorOnly: true);

FlogFlow.instance.onErrorRecorded = () async {
  final f = await FlogFlow.instance.exportIfError(); // null if there was no error
  if (f != null) await uploadToServer(f);
};

You record everything, but the flow is only kept if an error occurred β€” otherwise it's discarded. Ideal for leaving it always on without piling up healthy sessions.

Redacting sensitive data

RedactedZone(child: Text(card.number));       // never appears in captures
RedactedZone(child: TextField(obscureText: true));

The area is blacked out in the screenshot (your real UI doesn't change). Requires redactSensitive: true (the default).

Tapped widget

With identifyTapTargets: true, every tap stores the text of the widget under the finger (e.g. Add to bag) in target. It has a small per-tap cost (hit-test).

Session metadata

await FlogFlow.instance.start(
  appName: 'MyApp', appVersion: '1.4.2', build: '142', userId: 'u_123',
  metadata: {'flavor': 'prod'},
);

The rest (locale, OS version, screen size) is collected automatically.


Export encryption

Exports are always encrypted. Set a passphrase with encryptionPassphrase (or pass one to export()); the resulting .flowx file can only be opened in the viewer by entering that same passphrase.

await FlogFlow.instance.export();                    // uses the config's passphrase
await FlogFlow.instance.export(passphrase: 'other'); // or a per-call passphrase

Keep the passphrase secret and share it only with people who should see the recordings. If you don't set one, export() throws β€” there is no unencrypted output.

The container is versioned: new exports use v2 (PBKDF2 with 600k iterations, per the OWASP recommendation) and the viewer still opens older .flowx files (v1, 100k). Avoid compiling the passphrase into the binary in production: fetch it from your backend or from remote config.


Data deletion

By default (deleteAfterExport: true, cleanupOnStart: true) the session's data is deleted from disk after exporting, and starting a new recording clears any previous session β€” nothing persists between uses. The exported file lives outside the session folder, so it survives the cleanup. FlogFlow.instance.clear() does a full manual wipe.

On mobile, "on app close" doesn't arrive reliably when the OS kills the process; that's why deletion is anchored to exporting and to starting.


SaaS mode: connect to the platform with just a token

With your project token (generated at flog-flow.com) you don't need to export or move files around: everything uploads automatically and you get session history and statistics in the dashboard.

await FlogCloud.start(
  projectToken: 'flw_xxxxxxxxxxxxxxxx',       // from the platform
  encryptionPassphrase: 'PROJECT-PASSPHRASE',  // the file always travels encrypted
  appName: 'MyApp',
  appVersion: '1.4.2',
  // endpoint: 'https://api.mydomain.com',     // if you self-host
);

That single call replaces start() and wires up: recovery of pending sessions at startup, recording with the right settings, and automatic upload (background / close / crash) authenticated with your token. Each upload attaches an unencrypted summary (counts and metadata; never images or content) so the platform can index statistics without decrypting the file.

Local mode (manual export + viewer, no token) keeps working exactly the same: it's the way to try the library without signing up for anything.


Logs mode: logs only, no recording

If you don't need full session recording, FlogLogs is the lightweight option: it sends structured logs to the platform using the same token. It's independent of FlogCloud/FlogFlow (they can coexist or be used alone).

await FlogLogs.init(
  projectToken: 'flw_xxxxxxxxxxxxxxxx',
  service: 'my-app',          // default logname
  env: 'prod',
  version: '1.4.2',
  captureCrashes: true,       // automatic emergency on every crash (default)
);

FlogLogs.info('session started', attributes: {'userId': 'u_123'});
FlogLogs.warn('token about to expire', logname: 'auth');
FlogLogs.error('payment declined', error: e, stackTrace: st,
    screenshot: true, attributes: {'orderId': 'o_9', 'amount': 25.5});
FlogLogs.emergency('unrecoverable state');

Levels: emergency Β· error Β· warn Β· info Β· debug.

  • error/emergency always carry a stack trace (the one you pass, or the current one).
  • emergency is emitted automatically on every crash (Flutter errors + uncaught async errors) and is sent immediately, without waiting for the batch.
  • screenshot: true attaches a capture (useful for errors). Requires the app to be wrapped in FlogFlogScope; it respects RedactedZones.
  • Dynamic attributes per log, plus global ones with FlogLogs.setGlobalAttribute('userId', id) (e.g. after login).
  • Batching: batches every 10 s or 50 logs; flush on background; whatever couldn't be sent is saved to disk and re-sent on the next launch.
  • Every log carries automatic context: platform, OS version, and locale.

In the dashboard, the Logs tab lets you filter by status/logname, search, and inspect the stack trace, attributes, and screenshot of each log.

Log detail: summary, JSON, context, stack trace and jump to its session


Automatic upload to a server

Upload the encrypted .flowx file to your server automatically:

  • when the app goes to the background (paused) β€” the reliable trigger on mobile, and it also covers force quit: to kill the app from the switcher, the user sent it to the background first;
  • on a clean shutdown (detached, which almost never arrives on mobile β€” extra safety net);
  • on a Dart crash (recordError).

It only re-uploads if there's new content since the last successful upload. For OS kills or native crashes (where no Dart code runs), anything pending is retried on the next launch with recoverPendingUploads.

The file name carries the date and the reason for the upload:

flow_<sessionId>_<yyyy-MM-dd_HH-mm-ss>_<reason>.flowx
       β”‚           β”‚                     β”” background | close | crash | recovered | manual
       β”‚           β”” date of the event (for 'recovered', the original session's start date)
       β”” session id
final uploader = FlogHttpUpload(
  url: 'https://api.yourserver.com/flows',
  headers: {'Authorization': 'Bearer $token'},
  fields: {'app': 'MyApp'},                 // optional extra fields
);

// 1) at startup, BEFORE start(): re-send anything pending from earlier sessions
await FlogFlow.instance.recoverPendingUploads(
  upload: uploader,
  passphrase: 'CHANGE-THIS-PASSPHRASE',
);

// 2) start recording (same passphrase; keeps pending items that didn't upload)
await FlogFlow.instance.start(
  appName: 'MyApp',
  config: const CaptureConfig(
    encryptionPassphrase: 'CHANGE-THIS-PASSPHRASE',
    cleanupOnStart: false,     // don't delete what hasn't been uploaded yet
  ),
);

// 3) enable automatic upload (background + close + Dart crash)
FlogFlow.instance.enableAutoUpload(upload: uploader);
// optional: turn individual triggers off
// FlogFlow.instance.enableAutoUpload(upload: uploader, onBackground: false);

// 4) capture Dart crashes
FlutterError.onError = (d) =>
    FlogFlow.instance.recordError(d.exception, stackTrace: d.stack);
runZonedGuarded(() => runApp(MyApp()),
    (e, st) => FlogFlow.instance.recordError(e, stackTrace: st));
  • No useless duplicates: if the app goes in and out of the background several times with no new activity, it isn't re-uploaded. If the user kept using the app, the next trip to the background uploads the full updated session (keep the file with the most recent date per session on your server).
  • Your own upload: if you need different auth or a different format, pass a Future<bool> Function(File) instead of FlogHttpUpload.
  • Use the same passphrase in the config and in recoverPendingUploads, and cleanupOnStart: false so failed uploads are retried on the next launch.

Performance

Recording shouldn't be noticeable: captures are deferred and taken during idle moments. If it feels heavy on some device, tune the CaptureConfig:

  • lower pixelRatio (e.g. 0.5);
  • raise minCaptureInterval;
  • set captureOnTap: false (capture only on navigation) or recordScroll: false;
  • turn off the features you don't use (captureNetwork, captureLogs, framePerInteraction…).

Web viewer

The viewer is published online. Open flog-flow.com, upload your .flowx file and enter the passphrase it was recorded with. Nothing to install.

It's an app with a sidebar and 4 views:

  • Flow β€” the journey map; clicking a screen opens the detail of each interaction (with its thumbnail, timestamp, and the tapped widget) and the errors with their stack traces.
  • Playback β€” step-by-step replay with play/pause, speed control, and a timeline of every event (navigation, tap, scroll, network, log, breadcrumb, error).
  • Events β€” chronological list, filterable by type.
  • Session β€” device/app information and counts.

Notes: write per-screen comments and download them to share.


Known limitations

  • Platform views (Google Maps, WebView, camera, video) usually come out blank when rasterized: that's a platform restriction, not a package one.
  • On iOS, RepaintBoundary screenshots exclude DRM-protected content.
  • With framePerInteraction, each tap's frame is frozen at the instant of the interaction (encoding is deferred). Only in very long bursts (more than ~4 taps in a row with no pause) do the extras fall back to the classic deferred capture, which may show a later state of the screen.
  • The recorder doesn't run on Flutter web (it uses dart:io); on web you get the viewer only.

License

MIT.

Libraries

flog_flow
flog_flow