← Back to Blog

Flutter Localization Analytics: Track Language Usage and Translation Performance

flutteranalyticslocalizationfirebasetrackingmetricsroi

Flutter Localization Analytics: Track Language Usage and Translation Performance

Are your translations actually being used? Which languages drive the most engagement? Localization analytics helps you make data-driven decisions about translation investments. This guide shows you how to track language metrics in Flutter.

Why Track Localization Analytics?

Without analytics, you're guessing:

Question Without Analytics With Analytics
Which languages to prioritize? Guess based on downloads Data on actual usage
Are translations complete? Manual checking Automated coverage reports
Which strings are unused? No visibility Dead code detection
ROI of translation work Unknown Revenue per language

Core Metrics to Track

1. Language Distribution

class LocalizationAnalytics {
  final AnalyticsService _analytics;

  LocalizationAnalytics(this._analytics);

  /// Track when user's locale is determined
  void trackLocaleDetected(Locale locale, LocaleSource source) {
    _analytics.logEvent(
      name: 'locale_detected',
      parameters: {
        'language_code': locale.languageCode,
        'country_code': locale.countryCode ?? 'none',
        'source': source.name, // system, saved, manual
      },
    );
  }

  /// Track language changes
  void trackLanguageChanged(Locale from, Locale to) {
    _analytics.logEvent(
      name: 'language_changed',
      parameters: {
        'from_language': from.languageCode,
        'to_language': to.languageCode,
        'from_country': from.countryCode ?? 'none',
        'to_country': to.countryCode ?? 'none',
      },
    );
  }

  /// Track session with language
  void trackSessionStart(Locale locale) {
    _analytics.setUserProperty(
      name: 'app_language',
      value: locale.languageCode,
    );

    _analytics.logEvent(
      name: 'session_start_localized',
      parameters: {
        'language': locale.languageCode,
      },
    );
  }
}

enum LocaleSource {
  system,
  saved,
  manual,
  onboarding,
}

2. Translation Key Usage

class TranslationTracker {
  final Set<String> _usedKeys = {};
  final AnalyticsService _analytics;
  Timer? _batchTimer;

  TranslationTracker(this._analytics);

  /// Track when a translation key is accessed
  void trackKeyUsed(String key, String locale) {
    _usedKeys.add('$locale:$key');

    // Batch send to avoid too many events
    _batchTimer?.cancel();
    _batchTimer = Timer(const Duration(seconds: 30), _sendBatch);
  }

  void _sendBatch() {
    if (_usedKeys.isEmpty) return;

    _analytics.logEvent(
      name: 'translation_keys_used',
      parameters: {
        'count': _usedKeys.length,
        'keys': _usedKeys.take(100).join(','), // Limit for payload size
      },
    );

    _usedKeys.clear();
  }

  /// Track missing translations
  void trackMissingKey(String key, String locale) {
    _analytics.logEvent(
      name: 'translation_missing',
      parameters: {
        'key': key,
        'locale': locale,
        'timestamp': DateTime.now().toIso8601String(),
      },
    );
  }
}

3. Integration with Localizations

class TrackedLocalizations {
  final Map<String, String> _translations;
  final TranslationTracker _tracker;
  final String _locale;

  TrackedLocalizations(this._translations, this._tracker, this._locale);

  String translate(String key, [Map<String, dynamic>? params]) {
    // Track usage
    _tracker.trackKeyUsed(key, _locale);

    final value = _translations[key];

    if (value == null) {
      // Track missing
      _tracker.trackMissingKey(key, _locale);
      return key;
    }

    // Apply parameters
    String result = value;
    params?.forEach((k, v) {
      result = result.replaceAll('{$k}', v.toString());
    });

    return result;
  }
}

Firebase Analytics Integration

Setup

import 'package:firebase_analytics/firebase_analytics.dart';

class FirebaseLocalizationAnalytics implements LocalizationAnalytics {
  final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;

  @override
  void trackLocaleDetected(Locale locale, LocaleSource source) {
    _analytics.logEvent(
      name: 'locale_detected',
      parameters: {
        'language_code': locale.languageCode,
        'country_code': locale.countryCode ?? 'none',
        'source': source.name,
      },
    );
  }

  @override
  void trackLanguageChanged(Locale from, Locale to) {
    _analytics.logEvent(
      name: 'language_changed',
      parameters: {
        'from_language': from.languageCode,
        'to_language': to.languageCode,
      },
    );
  }

  @override
  void trackSessionStart(Locale locale) {
    // Set user property for segmentation
    _analytics.setUserProperty(
      name: 'app_language',
      value: locale.languageCode,
    );

    // Set for current session
    _analytics.setCurrentScreen(
      screenName: 'app_start',
      screenClassOverride: 'locale_${locale.languageCode}',
    );
  }

  /// Track feature usage by language
  void trackFeatureUsed(String feature, Locale locale) {
    _analytics.logEvent(
      name: 'feature_used',
      parameters: {
        'feature': feature,
        'language': locale.languageCode,
      },
    );
  }

  /// Track conversion by language
  void trackPurchase(double amount, String currency, Locale locale) {
    _analytics.logPurchase(
      currency: currency,
      value: amount,
      parameters: {
        'language': locale.languageCode,
      },
    );
  }
}

Custom Dashboards

Create Firebase Analytics dashboards for:

1. Language Distribution
   - Users by language (pie chart)
   - Sessions by language (bar chart)
   - New users by language (trend)

2. Language Switching Behavior
   - Switch rate per language
   - Common switch patterns (Sankey diagram)
   - Time to switch after install

3. Engagement by Language
   - Session duration by language
   - Screens per session by language
   - Retention by language

4. Revenue by Language
   - Purchases by language
   - ARPU by language
   - Conversion rate by language

Amplitude Integration

import 'package:amplitude_flutter/amplitude.dart';

class AmplitudeLocalizationAnalytics {
  final Amplitude _amplitude;

  AmplitudeLocalizationAnalytics(this._amplitude);

  void trackLocaleDetected(Locale locale, LocaleSource source) {
    // Set as user property for all future events
    _amplitude.setUserProperties({
      'language': locale.languageCode,
      'country': locale.countryCode ?? 'unknown',
      'locale_source': source.name,
    });

    _amplitude.logEvent(
      'Locale Detected',
      eventProperties: {
        'language_code': locale.languageCode,
        'country_code': locale.countryCode,
        'source': source.name,
      },
    );
  }

  void trackLanguageChanged(Locale from, Locale to) {
    // Update user property
    _amplitude.setUserProperties({
      'language': to.languageCode,
      'previous_language': from.languageCode,
      'language_changes_count': Identify()..add('language_changes', 1),
    });

    _amplitude.logEvent(
      'Language Changed',
      eventProperties: {
        'from_language': from.languageCode,
        'to_language': to.languageCode,
      },
    );
  }

  /// Track translation quality feedback
  void trackTranslationFeedback({
    required String key,
    required String locale,
    required int rating, // 1-5
    String? comment,
  }) {
    _amplitude.logEvent(
      'Translation Feedback',
      eventProperties: {
        'key': key,
        'locale': locale,
        'rating': rating,
        'has_comment': comment != null,
      },
    );
  }
}

Translation Coverage Tracking

Build-Time Analysis

// scripts/analyze_translations.dart
import 'dart:io';
import 'dart:convert';

void main() async {
  final sourceFile = File('lib/l10n/app_en.arb');
  final sourceArb = jsonDecode(await sourceFile.readAsString());

  final sourceKeys = sourceArb.keys
      .where((k) => !k.startsWith('@') && !k.startsWith('@@'))
      .toSet();

  print('Source (en): ${sourceKeys.length} keys\n');

  // Check each locale
  final localeDir = Directory('lib/l10n');
  final arbFiles = localeDir
      .listSync()
      .whereType<File>()
      .where((f) => f.path.endsWith('.arb') && !f.path.contains('_en.arb'));

  final coverage = <String, Map<String, dynamic>>{};

  for (final file in arbFiles) {
    final locale = RegExp(r'app_(\w+)\.arb').firstMatch(file.path)?.group(1);
    if (locale == null) continue;

    final arb = jsonDecode(await file.readAsString());
    final keys = arb.keys
        .where((k) => !k.startsWith('@') && !k.startsWith('@@'))
        .toSet();

    final missing = sourceKeys.difference(keys);
    final extra = keys.difference(sourceKeys);
    final translated = keys.intersection(sourceKeys);

    coverage[locale] = {
      'total': sourceKeys.length,
      'translated': translated.length,
      'missing': missing.length,
      'extra': extra.length,
      'percentage': (translated.length / sourceKeys.length * 100).round(),
      'missing_keys': missing.toList(),
    };

    print('$locale: ${coverage[locale]!['percentage']}% '
        '(${coverage[locale]!['translated']}/${sourceKeys.length})');

    if (missing.isNotEmpty) {
      print('  Missing: ${missing.take(5).join(', ')}...');
    }
  }

  // Output JSON for CI/CD
  final output = File('translation_coverage.json');
  await output.writeAsString(jsonEncode(coverage));
  print('\nCoverage report saved to translation_coverage.json');
}

Runtime Coverage

class TranslationCoverageTracker {
  final Set<String> _allKeys;
  final Set<String> _usedKeys = {};
  final String _locale;

  TranslationCoverageTracker(this._allKeys, this._locale);

  void markUsed(String key) {
    _usedKeys.add(key);
  }

  TranslationCoverage getCoverage() {
    final unused = _allKeys.difference(_usedKeys);

    return TranslationCoverage(
      locale: _locale,
      totalKeys: _allKeys.length,
      usedKeys: _usedKeys.length,
      unusedKeys: unused.toList(),
      usagePercentage: _usedKeys.length / _allKeys.length * 100,
    );
  }

  /// Send coverage report
  void reportCoverage(AnalyticsService analytics) {
    final coverage = getCoverage();

    analytics.logEvent(
      name: 'translation_coverage',
      parameters: {
        'locale': coverage.locale,
        'total_keys': coverage.totalKeys,
        'used_keys': coverage.usedKeys,
        'usage_percentage': coverage.usagePercentage.round(),
      },
    );
  }
}

class TranslationCoverage {
  final String locale;
  final int totalKeys;
  final int usedKeys;
  final List<String> unusedKeys;
  final double usagePercentage;

  TranslationCoverage({
    required this.locale,
    required this.totalKeys,
    required this.usedKeys,
    required this.unusedKeys,
    required this.usagePercentage,
  });
}

A/B Testing Translations

Testing Different Translations

class TranslationABTest {
  final FirebaseRemoteConfig _remoteConfig;
  final AnalyticsService _analytics;

  TranslationABTest(this._remoteConfig, this._analytics);

  /// Get translation variant
  String getTranslation(String key, String locale) {
    // Check if this key has an A/B test
    final testKey = 'translation_test_${locale}_$key';
    final variant = _remoteConfig.getString(testKey);

    if (variant.isNotEmpty) {
      // Track which variant was shown
      _analytics.logEvent(
        name: 'translation_variant_shown',
        parameters: {
          'key': key,
          'locale': locale,
          'variant': variant,
        },
      );

      return variant;
    }

    // Return default translation
    return _getDefaultTranslation(key, locale);
  }

  /// Track conversion for variant
  void trackConversion(String key, String locale) {
    final testKey = 'translation_test_${locale}_$key';
    final variant = _remoteConfig.getString(testKey);

    _analytics.logEvent(
      name: 'translation_variant_converted',
      parameters: {
        'key': key,
        'locale': locale,
        'variant': variant,
      },
    );
  }
}

// Usage - A/B test a CTA button
final ctaText = abTest.getTranslation('cta_subscribe', 'en');

ElevatedButton(
  onPressed: () {
    abTest.trackConversion('cta_subscribe', 'en');
    // ... handle subscription
  },
  child: Text(ctaText),
)

Quality Metrics

Translation Quality Score

class TranslationQualityTracker {
  final AnalyticsService _analytics;

  TranslationQualityTracker(this._analytics);

  /// Track user-reported issues
  void reportIssue({
    required String key,
    required String locale,
    required TranslationIssueType type,
    String? suggestion,
  }) {
    _analytics.logEvent(
      name: 'translation_issue_reported',
      parameters: {
        'key': key,
        'locale': locale,
        'issue_type': type.name,
        'has_suggestion': suggestion != null,
      },
    );
  }

  /// Track time spent on localized content
  void trackReadingTime({
    required String screenName,
    required String locale,
    required Duration duration,
  }) {
    _analytics.logEvent(
      name: 'content_reading_time',
      parameters: {
        'screen': screenName,
        'locale': locale,
        'duration_seconds': duration.inSeconds,
      },
    );
  }

  /// Track help/support requests by language
  void trackSupportRequest({
    required String locale,
    required String category,
  }) {
    _analytics.logEvent(
      name: 'support_request',
      parameters: {
        'locale': locale,
        'category': category,
      },
    );
  }
}

enum TranslationIssueType {
  typo,
  grammaticalError,
  wrongContext,
  offensive,
  unclear,
  missingTranslation,
  wrongLanguage,
}

Feedback Widget

class TranslationFeedbackButton extends StatelessWidget {
  final String translationKey;
  final TranslationQualityTracker tracker;

  const TranslationFeedbackButton({
    required this.translationKey,
    required this.tracker,
  });

  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);

    return IconButton(
      icon: const Icon(Icons.flag_outlined, size: 16),
      tooltip: 'Report translation issue',
      onPressed: () => _showFeedbackDialog(context, locale),
    );
  }

  void _showFeedbackDialog(BuildContext context, Locale locale) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Report Translation Issue'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: TranslationIssueType.values.map((type) {
            return ListTile(
              title: Text(_issueTypeLabel(type)),
              onTap: () {
                tracker.reportIssue(
                  key: translationKey,
                  locale: locale.languageCode,
                  type: type,
                );
                Navigator.pop(context);
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(content: Text('Thank you for your feedback!')),
                );
              },
            );
          }).toList(),
        ),
      ),
    );
  }

  String _issueTypeLabel(TranslationIssueType type) {
    switch (type) {
      case TranslationIssueType.typo:
        return 'Typo or spelling error';
      case TranslationIssueType.grammaticalError:
        return 'Grammar issue';
      case TranslationIssueType.wrongContext:
        return 'Wrong context/meaning';
      case TranslationIssueType.unclear:
        return 'Unclear or confusing';
      case TranslationIssueType.missingTranslation:
        return 'Missing translation';
      default:
        return type.name;
    }
  }
}

Dashboard Queries

BigQuery for Firebase

-- Users by language
SELECT
  user_properties.value.string_value AS language,
  COUNT(DISTINCT user_pseudo_id) AS users
FROM `project.analytics_xxx.events_*`
WHERE user_properties.key = 'app_language'
  AND _TABLE_SUFFIX BETWEEN '20250101' AND '20251231'
GROUP BY language
ORDER BY users DESC;

-- Language switch patterns
SELECT
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'from_language') AS from_lang,
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'to_language') AS to_lang,
  COUNT(*) AS switches
FROM `project.analytics_xxx.events_*`
WHERE event_name = 'language_changed'
GROUP BY from_lang, to_lang
ORDER BY switches DESC;

-- Revenue by language
SELECT
  (SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'app_language') AS language,
  SUM(event_value_in_usd) AS revenue,
  COUNT(DISTINCT user_pseudo_id) AS paying_users
FROM `project.analytics_xxx.events_*`
WHERE event_name = 'purchase'
GROUP BY language
ORDER BY revenue DESC;

Best Practices

1. Track from Day One

// main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final analytics = LocalizationAnalytics();

  // Track initial locale detection
  final deviceLocale = PlatformDispatcher.instance.locale;
  analytics.trackLocaleDetected(deviceLocale, LocaleSource.system);

  runApp(const MyApp());
}

2. Respect Privacy

class PrivacyAwareAnalytics {
  bool _trackingEnabled = true;

  Future<void> initialize() async {
    // Check user consent
    _trackingEnabled = await _hasTrackingConsent();
  }

  void logEvent(String name, Map<String, dynamic> params) {
    if (!_trackingEnabled) return;
    // ... log event
  }
}

3. Sample High-Volume Events

class SampledTracker {
  final double _sampleRate;
  final Random _random = Random();

  SampledTracker({double sampleRate = 0.1}) : _sampleRate = sampleRate;

  void trackKeyUsed(String key, String locale) {
    // Only track 10% of events
    if (_random.nextDouble() > _sampleRate) return;

    _analytics.logEvent(
      name: 'translation_key_used_sampled',
      parameters: {
        'key': key,
        'locale': locale,
        'sample_rate': _sampleRate,
      },
    );
  }
}

Conclusion

Localization analytics helps you:

  • Prioritize languages based on actual usage
  • Find dead translations that waste money
  • Measure ROI of translation investments
  • Improve quality through user feedback

Start tracking today to make data-driven localization decisions.

For professional translation management with built-in analytics, check out FlutterLocalisation.


Related Articles: