FlogFlow
Observability for Flutter β Logs + Session Replay in a single SDK.
π 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
.flowxfile.
[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
projectTokencome from? Go to flog-flow.com, create your account (with Google) and a project: that's where you get yourflw_β¦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.

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.

β οΈ 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
httppackage instead of Dio the interceptor doesn't apply: record the key requests withFlogFlow.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.

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
Navigation and labels
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.
Breadcrumbs and logs
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 respectsRedactedZones. - 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.

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 ofFlogHttpUpload. - Use the same passphrase in the config and in
recoverPendingUploads, andcleanupOnStart: falseso 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) orrecordScroll: 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,
RepaintBoundaryscreenshots 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