farol_flutter 0.1.0
farol_flutter: ^0.1.0 copied to clipboard
Flutter auto-instrumentation for the Faro RUM client — lifecycle, errors, navigation.
farol_flutter #
Flutter auto-instrumentation for farol: app lifecycle events,
cold-start timing, uncaught-error reporting, and route/view tracking — wired
up with a single initialize() call.
Implements the Grafana Faro wire protocol. Not affiliated with or endorsed by Grafana Labs.
Install #
dependencies:
farol: ^0.1.0
farol_flutter: ^0.1.0
Or, to track this repo directly instead of pub.dev:
dependencies:
farol:
git:
url: https://github.com/thegorangers/farol.git
path: packages/farol
farol_flutter:
git:
url: https://github.com/thegorangers/farol.git
path: packages/farol_flutter
Usage #
import 'package:farol/farol.dart';
import 'package:farol_flutter/farol_flutter.dart';
import 'package:flutter/material.dart';
void main() {
FaroFlutter.initialize(FaroConfig(
collectorUrl: Uri.parse('https://faro-collector.example.com/collect'),
app: const FaroApp(name: 'my-app', version: '1.0.0'),
headersProvider: () async => {'x-api-key': await readApiKey()},
));
runApp(MaterialApp(
navigatorObservers: [FaroNavigatorObserver()],
home: const HomePage(),
));
}
FaroFlutter.initialize calls Faro.initialize internally, then:
- measures cold-start time (from
initialize()to first frame), - reports
app_background/app_foregroundlifecycle events (flushing on background, so the batch isn't lost if the process is later killed), - installs
FlutterError.onErrorandPlatformDispatcher.instance.onErrorhooks for uncaught error reporting (deduplicated across the two channels so a framework error and its rethrow-as-async-error aren't double-counted).
Add FaroNavigatorObserver() to navigatorObservers to get automatic
setView/view_changed tracking on named-route push/pop/replace.
Call FaroFlutter.dispose() to remove the lifecycle observer (e.g. in
tests or a hot-reload teardown path). Calling initialize() again after
dispose() re-wires everything from scratch; calling it a second time
without an intervening dispose() is a no-op — it does not re-apply a
new config/options.
Signals overview #
FaroFlutter.initialize auto-collects the following, each individually
toggleable via FaroFlutterOptions:
| Signal | Wire name(s) | Option |
|---|---|---|
| Cold start | cold_start measurement (cold_start_ms) |
always on |
| App lifecycle | app_background / app_foreground events |
always on |
| Foreground resume | foreground_resume measurement (foreground_resume_ms) |
collectForegroundResume |
| Uncaught errors | pushError (FlutterError + PlatformDispatcher, deduplicated) |
always on |
| View/route changes | view_changed event + setView |
via FaroNavigatorObserver |
| Time-to-initial-display | ttid measurement (ttid_ms) |
collectTtid |
| Time-to-fully-displayed | ttfd measurement (ttfd_ms) |
manual call, see below |
| Frame jank | frames measurement (frames_total/frames_slow/frames_frozen/frame_delay_ms), context: {'view': ...} |
collectFrameJank / collectFrameJankInDebug |
| Rage taps | rage_tap event (count) |
mount FaroRageTapDetector, see below |
Options #
FaroFlutter.initialize(
config,
options: const FaroFlutterOptions(
collectFrameJank: true,
collectFrameJankInDebug: false,
collectForegroundResume: true,
detectRageTaps: true,
collectTtid: true,
longScreenFlushInterval: Duration(seconds: 30),
slowFrameThreshold: Duration(microseconds: 16667),
frozenFrameThreshold: Duration(milliseconds: 700),
rageTapCount: 4,
rageTapWindow: Duration(milliseconds: 1000),
rageTapRadius: 48.0,
rageTapCooldown: Duration(seconds: 3),
),
);
The values shown above are the defaults. collectFrameJank,
collectForegroundResume, and collectTtid are wired directly by
initialize(). detectRageTaps and the rageTap* fields are not
hard-wired — see "Rage-tap mounting" below for why, and how to use them.
Debug-mode no-op for frame jank #
By default (collectFrameJankInDebug: false), the frame-jank collector is
not installed in debug builds (kDebugMode), even when
collectFrameJank: true. Debug-mode frame timings are dominated by JIT
compilation and assertion overhead and would produce noisy,
unrepresentative jank numbers. Set collectFrameJankInDebug: true to
opt in anyway (e.g. to smoke-test the pipeline locally).
120Hz / high-refresh-rate caveat #
slowFrameThreshold's default (16667µs ≈ one 60Hz frame budget) assumes a
60Hz target. On a 90Hz/120Hz device a "slow" 60Hz-budget frame is a normal
frame at the device's native refresh rate — pass a tighter
slowFrameThreshold if you want jank to reflect the device's actual
refresh rate rather than a fixed 60Hz baseline.
frames context attribution note #
frames measurements carry an explicit context: {'view': ...} rather
than relying on Faro's ambient "current view" meta. addTimingsCallback
batches are delivered on a delay (observed ~1s in release builds); by the
time a batch arrives, the ambient current view may already be the next
screen. Each frame is instead attributed to the view whose tracked
interval actually contained the frame's vsync timestamp.
Rage-tap mounting #
FaroRageTapDetector is a widget, not something initialize() can wire
into your tree automatically — mount it once, high in your widget tree
(e.g. wrapping MaterialApp.builder):
MaterialApp(
navigatorObservers: [FaroNavigatorObserver()],
builder: (context, child) => FaroRageTapDetector(child: child!),
home: const HomePage(),
)
FaroFlutterOptions.detectRageTaps and the rageTap* fields are not
hard-wired into FaroRageTapDetector — the widget's own constructor
parameters (count, window, radius, cooldown) are the single source
of truth, set at the mounting site:
FaroRageTapDetector(
count: options.rageTapCount,
window: options.rageTapWindow,
radius: options.rageTapRadius,
cooldown: options.rageTapCooldown,
child: child!,
)
This keeps FaroFlutterOptions a plain data holder (no hidden global
wiring for a widget-shaped signal) — detectRageTaps is documentation of
intent, not an implicit switch.
Wrap any widget where fast, repeated taps are legitimate (steppers,
keypads, +/- buttons) in FaroRageTapIgnore so it's excluded from rage-tap
detection without changing its hit-testing behavior:
FaroRageTapIgnore(
child: IconButton(onPressed: increment, icon: const Icon(Icons.add)),
)
Time-to-fully-displayed (TTFD) #
TTID (above) measures "first frame of the new screen"; TTFD measures "the
screen's real content — e.g. data loaded from the network — is actually on
screen." Call FaroFlutter.reportFullyDisplayed() once your screen's
meaningful content has rendered:
@override
void initState() {
super.initState();
loadData().then((_) {
setState(() { /* ... */ });
FaroFlutter.reportFullyDisplayed();
});
}
reportFullyDisplayed() emits a ttfd measurement (ttfd_ms, measured
from the view's startView timestamp) at most once per view — a
second call before the next navigation is silently ignored, so callers
don't need to guard against calling it from multiple code paths (e.g. a
data-loaded callback and a timeout fallback both racing to call it). It's
also a no-op if Faro isn't initialized.
Endpoint & auth model #
Identical to farol: collectorUrl + a pluggable headersProvider on
FaroConfig for auth (API key, bearer token, etc). farol_flutter adds no
auth of its own — it only feeds Flutter-specific signals into the same
Faro instance.
No-PII rule #
FaroNavigatorObserverreportsroute.settings.name— use static route names, not raw user data, as route names (this is standard Flutter practice; avoid encoding user IDs or free text into route names).- Anonymous routes (
settings.name == null) are skipped, not reported with a generatedtoString(), to avoid noisy/non-deterministic view names. - Uncaught-error reporting forwards
error.toString()and the stack trace — don't throw exceptions whosetoString()embeds PII. - As with
farol, useFaroConfig.beforeSendfor centralized scrubbing if needed.