Capire come gli utenti usano davvero la nostra app è la differenza tra sviluppare a sensazione e sviluppare sui dati. Firebase Analytics (basato su Google Analytics 4) è la soluzione più diffusa nel mondo Flutter: è gratuita, funziona su Android, iOS e Web, e si integra con il resto dell'ecosistema Firebase (Crashlytics, Remote Config, A/B Testing).

In questo articolo vediamo come integrarlo in modo pulito e manutenibile, evitando l'errore classico di spargere chiamate a FirebaseAnalytics.instance in mezzo ai widget.

Setup iniziale

Dopo aver configurato il progetto Firebase con la CLI (flutterfire configure), aggiungiamo le dipendenze:

dependencies:
  firebase_core: ^3.8.0
  firebase_analytics: ^11.3.6

Inizializziamo Firebase nel main:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}

Su Android non serve altro: il plugin raccoglie automaticamente eventi come first_open, session_start, app_update e app_remove. Su iOS, se vuoi tracciare la provenienza delle installazioni, ricordati di gestire l'App Tracking Transparency (ne parliamo più avanti).

Eventi predefiniti, eventi custom e i limiti di GA4

GA4 mette a disposizione una serie di eventi predefiniti con parametri già mappati nei report: login, sign_up, search, select_content, purchase, add_to_cart. Usarli quando esistono è sempre la scelta migliore, perché alimentano dashboard e funnel già pronti.

final analytics = FirebaseAnalytics.instance;

await analytics.logLogin(loginMethod: 'google');
await analytics.logSearch(searchTerm: 'scarpe da running');
await analytics.logSelectContent(contentType: 'product', itemId: 'SKU-1234');

Quando l'evento è specifico del tuo dominio, si usa logEvent:

await analytics.logEvent(
  name: 'recipe_saved',
  parameters: <String, Object>{
    'recipe_id': 'r_8891',
    'category': 'primi',
    'from_screen': 'recipe_detail',
    'is_premium': 1, // niente bool: usa 0/1
  },
);

Attenzione ai vincoli di GA4, perché vengono ignorati silenziosamente:

  • massimo 500 nomi di evento distinti per app;
  • nome evento: max 40 caratteri, deve iniziare con una lettera, solo lettere/numeri/underscore;
  • massimo 25 parametri per evento;
  • nome parametro max 40 caratteri, valore stringa max 100 caratteri;
  • i parametri accettano solo String e num: i booleani vanno convertiti in 0/1;
  • i nomi che iniziano con firebase_, google_ o ga_ sono riservati.

Un'altra regola d'oro: non registrare mai dati personali (email, numeri di telefono, nomi, ID fiscali) nei parametri. Oltre a essere una violazione del GDPR, viola anche i termini d'uso di Firebase.

Un servizio di analytics astratto

Chiamare direttamente FirebaseAnalytics.instance dai widget rende il codice difficile da testare e lega la UI a un SDK di terze parti. Meglio definire un'interfaccia e un catalogo tipizzato di eventi.

abstract interface class AnalyticsService {
  Future<void> logEvent(AnalyticsEvent event);
  Future<void> setUserId(String? id);
  Future<void> setUserProperty(String name, String? value);
  Future<void> logScreen(String name, {String? screenClass});
}

/// Catalogo degli eventi: una sola fonte di verità per nomi e parametri.
sealed class AnalyticsEvent {
  const AnalyticsEvent();

  String get name;
  Map<String, Object> get parameters => const {};
}

final class RecipeSaved extends AnalyticsEvent {
  const RecipeSaved({required this.recipeId, required this.category});

  final String recipeId;
  final String category;

  @override
  String get name => 'recipe_saved';

  @override
  Map<String, Object> get parameters => {
        'recipe_id': recipeId,
        'category': category,
      };
}

final class CheckoutStarted extends AnalyticsEvent {
  const CheckoutStarted({required this.value, required this.itemCount});

  final double value;
  final int itemCount;

  @override
  String get name => 'checkout_started';

  @override
  Map<String, Object> get parameters => {
        'value': value,
        'currency': 'EUR',
        'item_count': itemCount,
      };
}

L'implementazione Firebase diventa banale e completamente isolata:

class FirebaseAnalyticsService implements AnalyticsService {
  FirebaseAnalyticsService(this._analytics);

  final FirebaseAnalytics _analytics;

  @override
  Future<void> logEvent(AnalyticsEvent event) async {
    try {
      await _analytics.logEvent(
        name: event.name,
        parameters: event.parameters.isEmpty ? null : event.parameters,
      );
    } catch (e, s) {
      // L'analytics non deve mai far crashare l'app.
      debugPrint('Analytics error: $e\n$s');
    }
  }

  @override
  Future<void> setUserId(String? id) => _analytics.setUserId(id: id);

  @override
  Future<void> setUserProperty(String name, String? value) =>
      _analytics.setUserProperty(name: name, value: value);

  @override
  Future<void> logScreen(String name, {String? screenClass}) =>
      _analytics.logScreenView(screenName: name, screenClass: screenClass);
}

In ambiente di sviluppo puoi sostituirla con una DebugAnalyticsService che stampa gli eventi in console, oppure con una NoopAnalyticsService nei test, senza toccare una riga di UI.

Tracciare le schermate con go_router

Su Flutter il tracking automatico delle schermate non funziona come sul nativo: il framework vede una sola Activity/ViewController. Serve quindi un NavigatorObserver.

Con la navigazione classica basta aggiungere l'observer fornito dal plugin:

MaterialApp(
  navigatorObservers: [
    FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance),
  ],
);

Con go_router conviene invece scrivere un observer che usi il nome della rotta, molto più leggibile dei path con parametri:

class AnalyticsRouteObserver extends NavigatorObserver {
  AnalyticsRouteObserver(this._analytics);

  final AnalyticsService _analytics;

  void _track(Route<dynamic>? route) {
    final settings = route?.settings;
    if (route is! PageRoute || settings == null) return;

    final name = settings.name;
    if (name == null || name.isEmpty) return;

    _analytics.logScreen(name, screenClass: route.runtimeType.toString());
  }

  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) =>
      _track(route);

  @override
  void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) =>
      _track(previousRoute);

  @override
  void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) =>
      _track(newRoute);
}

E si registra così:

final router = GoRouter(
  observers: [AnalyticsRouteObserver(analyticsService)],
  routes: [
    GoRoute(
      path: '/recipes/:id',
      name: 'recipe_detail', // questo nome finirà in screen_view
      builder: (context, state) => RecipeDetailPage(
        id: state.pathParameters['id']!,
      ),
    ),
  ],
);

Attenzione: con ShellRoute e StatefulShellRoute (bottom navigation persistente) il cambio di tab non genera un push, quindi l'observer non scatta. In quei casi va loggato manualmente lo screen_view nel callback di cambio branch.

User ID e user properties

Le user properties sono attributi persistenti dell'utente (massimo 25 per progetto) e permettono di segmentare tutti i report: sono lo strumento ideale per rispondere a domande come "gli utenti premium completano il checkout più spesso?".

Future<void> onUserLoggedIn(User user) async {
  // ID pseudonimo, mai l'email
  await analyticsService.setUserId(user.uid);
  await analyticsService.setUserProperty('plan', user.isPremium ? 'premium' : 'free');
  await analyticsService.setUserProperty('app_theme', 'dark');
}

Future<void> onLogout() async {
  await analyticsService.setUserId(null);
  await analyticsService.setUserProperty('plan', null);
}

Ricorda di ripulire le proprietà al logout, altrimenti i dati del vecchio utente continueranno a segmentare la sessione del nuovo.

Consenso, privacy e GDPR

In Europa la raccolta dati richiede consenso esplicito. La strategia corretta è disattivare la raccolta di default e abilitarla solo dopo l'accettazione.

Su Android, nel AndroidManifest.xml:

<meta-data
    android:name="firebase_analytics_collection_enabled"
    android:value="false" />

Su iOS, in Info.plist:

<key>FIREBASE_ANALYTICS_COLLECTION_ENABLED</key>
<false/>

Poi, a runtime:

Future<void> applyConsent({required bool analyticsAllowed, required bool adsAllowed}) async {
  final analytics = FirebaseAnalytics.instance;

  await analytics.setAnalyticsCollectionEnabled(analyticsAllowed);

  await analytics.setConsent(
    analyticsStorageConsentGranted: analyticsAllowed,
    adStorageConsentGranted: adsAllowed,
    adUserDataConsentGranted: adsAllowed,
    adPersonalizationSignalsConsentGranted: adsAllowed,
  );
}

Su iOS, se usi l'IDFA per l'attribuzione, devi anche richiedere il permesso ATT (pacchetto app_tracking_transparency) prima di abilitare la raccolta, e dichiarare l'uso dei dati nel Privacy Manifest richiesto da Apple.

Debug: vedere gli eventi in tempo reale

Gli eventi vengono inviati in batch (tipicamente ogni ora), quindi in sviluppo serve la DebugView della console Firebase.

Su Android, da terminale:

adb shell setprop debug.firebase.analytics.app com.example.myapp
# per disattivare:
adb shell setprop debug.firebase.analytics.app .none.

Su iOS, aggiungi l'argomento -FIRDebugEnabled negli argomenti di lancio dello schema Xcode (o flutter run --dart-define non basta: serve l'argomento nativo).

Un trucco utile: nella build di debug, invece di inviare a Firebase, logga a console.

class ConsoleAnalyticsService implements AnalyticsService {
  @override
  Future<void> logEvent(AnalyticsEvent event) async {
    debugPrint('📊 ${event.name} → ${event.parameters}');
  }
  // ...altre implementazioni no-op
}

final AnalyticsService analyticsService = kDebugMode
    ? ConsoleAnalyticsService()
    : FirebaseAnalyticsService(FirebaseAnalytics.instance);

Così eviti di sporcare i dati di produzione con il traffico degli sviluppatori (in alternativa, filtra il traffico interno con una user property env: dev).

Testare il tracking

Avere un'interfaccia astratta rende i test banali:

class FakeAnalyticsService implements AnalyticsService {
  final List<AnalyticsEvent> events = [];

  @override
  Future<void> logEvent(AnalyticsEvent event) async => events.add(event);

  @override
  Future<void> logScreen(String name, {String? screenClass}) async {}

  @override
  Future<void> setUserId(String? id) async {}

  @override
  Future<void> setUserProperty(String name, String? value) async {}
}

testWidgets('salvando la ricetta viene tracciato recipe_saved', (tester) async {
  final fake = FakeAnalyticsService();
  await tester.pumpWidget(buildApp(analytics: fake));

  await tester.tap(find.byIcon(Icons.bookmark_outline));
  await tester.pumpAndSettle();

  expect(fake.events.single, isA<RecipeSaved>());
  expect(fake.events.single.parameters['category'], 'primi');
});

Errori comuni da evitare

  • Tracciare tutto: ogni evento senza una domanda di business dietro è rumore. Parti da 10-15 eventi chiave legati al funnel principale.
  • Nomi incoerenti: ButtonClick, button_clicked, btn_click diventano tre eventi diversi e inutilizzabili. Il catalogo tipizzato risolve alla radice.
  • Alta cardinalità nei parametri: usare un recipe_id come dimensione va bene, ma non aspettarti report leggibili se i valori distinti sono decine di migliaia.
  • Await bloccanti: non aspettare mai il completamento di un log prima di aggiornare la UI; l'analytics è fire-and-forget.
  • Dimenticare la registrazione delle dimensioni custom: in GA4 i parametri custom vanno registrati come dimensioni o metriche personalizzate nella console, altrimenti non compaiono nei report standard.

Conclusioni

Firebase Analytics è potente, ma il valore dipende quasi interamente dalla disciplina con cui lo si integra. Definire un catalogo di eventi tipizzato, isolare l'SDK dietro un'interfaccia, tracciare automaticamente le schermate tramite un observer e gestire correttamente il consenso sono i quattro passi che trasformano un tracking caotico in una fonte di dati affidabile — e testabile — su cui costruire decisioni di prodotto.