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:
- Flutter Localization Testing Strategies
- Flutter Localization Performance
- Free ARB Editor - Manage your translations