Analytics: fespalier_analytics
fespalier_analytics (since 0.13.0) reports screen views to an analytics service of yours: each page the person opens, by its route pattern (/products/:id), never by its URL, and only while the person has agreed. It is a telemetry sink that an adapter installs, so it sits next to fespalier_otel or fespalier_sentry in the one combine, and it needs an app generated with telemetry: true: page events reach a sink only from such an app.
The analytics SDK is not a dependency. You give the package an AnalyticsBackend (three methods), and Firebase Analytics, PostHog, Mixpanel and Amplitude are compiled recipes in the fespalier-observability skill, so an app on one does not link the others. The package does not depend on fespalier_otel, starts no timer and listens to nothing.
Install
Add fespalier_analytics under dependencies: next to fespalier, with the same git url and the same ref: pub resolves the two to one package only if they are the same repository dependency. The snippet, kept at the release's tag, is in packages/fespalier_analytics/README.md. Then turn telemetry on, list the adapter and run fsp gen:
# pubspec.yaml
fespalier:
telemetry: true
adapters: [fespalier_analytics]An adapter takes no options from the pubspec, so the app's own code reaches it through one static call in main(), before AppMain.run() (Adapters that need your code):
// lib/main.dart
Future<void> main() {
FespalierAnalytics.configure(MyAnalyticsBackend(), screenName: screenName);
return AppMain.run();
}configure(backend, {screenName, returningViews = true, screenTime = true, consent = undecided}). Calling it twice replaces: the earlier sink stops reporting and the new one takes its place in the telemetry slot (a test, or an app that changes backend; a hot restart resets static state and starts from nothing).- The adapter's
beforeRun()adds the sink withFespalierTelemetry.add, synchronously, so the first navigation is reported. Itsattachasks core'stelemetryFollows(router): withouttelemetry: trueit reports once (fespalier_analytics records no screen: ... Set telemetry: true under fespalier: in pubspec.yaml and run fsp gen) and records nothing.telemetry: truealso adds the call sites that reportdata()and actions, which is a cost to know (What it costs). - Add other sinks with
FespalierTelemetry.add, neverinstallafterAppMain.run()started. The sink is added inbeforeRun();FespalierTelemetry.install(...)instartup()(the usual Sentry or OpenTelemetry wiring) replaces the whole slot and drops it, silently. UseFespalierTelemetry.add(FespalierSentry())there, orinstallinmain()beforeAppMain.run(). The adapter'sattachchecksFespalierTelemetry.contains(sink)(since 0.13.0) and reports once when the sink is gone. - Unconfigured, the adapter says so once and does nothing: one
FlutterErrorreportsfespalier_analytics is listed under `fespalier: adapters:` but was never configured: call FespalierAnalytics.configure(backend) in main() before AppMain.run(). It never throws out ofbeforeRunorattach. - With
main: manual, callconfigurein your ownmain()beforeAppAdapters.zone.
Screen names
A ScreenView has a name, the route pattern, a source and returning:
- The name is
screenName(pattern), which is the pattern itself by default. Returnnull(or an empty string) to skip a screen, and its time with it. Not-found pages have no pattern and are not screens. - The pattern holds the segment's name, never its value:
/orders/:id, not/orders/42. A query value, a fragment and a segment value are not in anything the backend is given; the package's tests assert it on every string a backend receives. Whatever yourscreenNamereturns is yours: do not build a name from a URL. sourceis aNavigationSource(notification,shortcut,widget,link) when the navigation that showed the screen was not the app's own (Where a navigation came from): a screen opened by a tap on a notification says so.returningis a screen that is the visible one again (back to a tab, a pop) and not a first entry.returningViews: falsedrops those.
A name from your meta.dart (Route manifest and meta.dart) is a two-line screenName; the recipe is in the skill.
Consent
Nothing is sent, and nothing is kept, until the person has agreed.
AnalyticsConsentisundecided(the start),grantedordenied. While it is notgranted, a page event is dropped on the spot: it reaches no backend, and there is no buffer and no remembered "last screen".- Granting does not replay what was dropped. The screen that was open when the person answered is not reported (and is not timed when it is left); the next screen is the first. Asking on the first screen therefore loses that one: ask on a screen you do not count (
screenNamereturnsnullfor it), or accept the loss. - Load the stored decision in
startup(), which runs before the router is built, so the first screen is not lost:FespalierAnalytics.sink?.consent = stored;. The same value again does nothing. - Changing it tells the backend (
AnalyticsBackend.consentChanged): map it to the vendor's own switch (setConsent,optInandoptOut). A backend that throws inconsentChangedis reported (FlutterError, because the app's own call made it) and the decision stands. The backend is not told the initialconsent:ofconfigure: that is the SDK's own start-up (and a vendor's collection should be off by default until the person answers). A stored answer therefore goes throughFespalierAnalytics.sink?.consent = storedinstartup(), which does tell the backend (deniedorgranted;undecidedis the sink's own start and calls nothing). analyticsConsentis aNotifierProviderfor a settings screen:ref.watch(analyticsConsent)reads it andref.read(analyticsConsent.notifier).set(AnalyticsConsent.granted)changes it through the sink. It starts at the sink's decision; after the first frame use it, not the sink, so the two agree.- The consent change is itself a span,
fespalier.analytics.consentwithfespalier.analytics.state(undecided,grantedordenied); nothing else about analytics is reported to the other sinks.
Storing the answer, and asking for it, are the app's: the package has no storage and no banner.
Time on screen
screenTime: true (the default) reports a ScreenTime when a screen is left: the time from its entry to its leaving, the time another page covered it included. It is sent only for a screen whose view was sent, so a screen entered before the person agreed is never timed. A screen that is covered by a pushed page is not left until it is gone; a tab kept alive in a shell is not left when you switch away. screenTime: false sends views only. Limit: the package pairs a leave with a view by the pattern alone, so with two instances of one pattern open at once (/orders/1 under /orders/2) the leaves are matched to views by count, not by instance, and a time may belong to the other instance. Views are never affected.
Backends
An AnalyticsBackend is called synchronously from the router and must return at once:
| Member | When |
|---|---|
screenView(ScreenView) | A screen came up. Only while consent is granted. |
screenTime(ScreenTime) | A screen was left (default: nothing). Only while consent is granted, for a screen already viewed. |
consentChanged(consent) | The decision changed (default: nothing). |
A vendor call that returns a Future is ignored with its own onError (unawaited(call().catchError(...))), never awaited; a backend that throws in screenView or screenTime is isolated by fespalier's telemetry (the others and the app go on, the error is printed once), and a view that threw is not timed; one that throws in consentChanged is reported as above. Firebase Analytics, PostHog, Mixpanel and Amplitude are recipes in the fespalier-observability skill (references/analytics-backends.md), built by just skill-samples. A vendor SDK's own automatic screen tracking must be switched off, or a screen is counted twice.
Testing
package:fespalier_analytics/testing.dart has RecordingAnalytics: views, times, consents, a log and strings (everything it was given, as strings, to assert that no URL leaked); throwing: true makes every call fail. A widget test boots the app as it runs, with configure called first, because AppMain.root() runs no main():
FespalierAnalytics.configure(recorder, consent: AnalyticsConsent.granted);
await AppAdapters.beforeRun(); // what main() does before runApp
await tester.pumpWidget(AppMain.root());
expect(recorder.views.map((v) => v.pattern), ['/']);Call FespalierAnalytics.debugReset() in setUp and tearDown, and FespalierTelemetry.install(null) in tearDown (the sink is added to the telemetry slot). examples/plugins tests the first screen, undecided then granted, a refusal, a notification source, a skipped screen and the absence of any segment value through AppMain.root(); no device is needed. What no test can see is the vendor's own side: that Firebase or PostHog really receives the event, and honours the opt-out, on a device.