improve Firebase integration across Crashlytics, Analytics, and Performance

- Fix cold-start privacy bug: apply persisted collection preference on launch
- Disable Firebase collection in debug builds to avoid polluting prod data
- Add Crashlytics custom keys (is_logged_in, channel), user identifier, and
  WebSocket reconnection breadcrumbs for actionable crash reports
- Record non-fatal errors for server failures, WebSocket errors, and global
  asset fetch failures (skip timeouts/network errors to avoid noise)
- Update is_logged_in key and user identifier on login/logout transitions
- Log auth reconnection exhaustion before forced logout
- Add FirebaseAnalyticsObserver with named routes for automatic screen tracking
- Add static routeName constants to avoid duplicated string literals
- Add Firebase Performance Dio interceptor for automatic HTTP request tracing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-08 00:16:13 -08:00
parent f07fe621bc
commit 6ede000c66
21 changed files with 191 additions and 41 deletions

View file

@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
/// Common type aliases to reduce repetition
typedef JsonMap = Map<String, dynamic>;
@ -157,29 +158,35 @@ abstract class BaseApiClient {
/// Converts DioException to appropriate ApiException subtype
ApiException _handleError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return TimeoutException(_getTimeoutMessage(error.type));
final exception = switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
TimeoutException(_getTimeoutMessage(error.type)),
DioExceptionType.connectionError => NetworkException(
'No internet connection. Please check your network.',
),
DioExceptionType.badResponse => _handleHttpError(error),
DioExceptionType.cancel => ApiException('Request was cancelled'),
DioExceptionType.badCertificate =>
ApiException('Security certificate error'),
DioExceptionType.unknown =>
ApiException(error.message ?? 'An unexpected error occurred'),
};
case DioExceptionType.connectionError:
return NetworkException(
'No internet connection. Please check your network.',
);
case DioExceptionType.badResponse:
return _handleHttpError(error);
case DioExceptionType.cancel:
return ApiException('Request was cancelled');
case DioExceptionType.badCertificate:
return ApiException('Security certificate error');
case DioExceptionType.unknown:
return ApiException(error.message ?? 'An unexpected error occurred');
// Record server errors and unexpected failures to Crashlytics.
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
if (exception is ServerException ||
error.type == DioExceptionType.unknown ||
error.type == DioExceptionType.badCertificate) {
FirebaseCrashlytics.instance.recordError(
exception,
error.stackTrace,
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
);
}
return exception;
}
/// Gets specific timeout message based on timeout type

View file

@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/firebase_performance_interceptor.dart';
/// Centralized Dio client configuration with interceptors and error handling
class DioClient {
@ -55,6 +56,9 @@ class DioClient {
);
}
// Trace HTTP requests as Firebase Performance metrics
dio.interceptors.add(FirebasePerformanceInterceptor());
// Add any additional interceptors provided
if (additionalInterceptors != null) {
for (final interceptor in additionalInterceptors) {

View file

@ -0,0 +1,46 @@
import 'package:dio/dio.dart';
import 'package:firebase_performance/firebase_performance.dart';
/// Dio interceptor that traces HTTP requests as Firebase Performance metrics.
class FirebasePerformanceInterceptor extends Interceptor {
static const _metricKey = 'firebase_performance_metric';
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final metric = FirebasePerformance.instance.newHttpMetric(
options.uri.toString(),
_getHttpMethod(options.method),
);
metric.start();
options.extra[_metricKey] = metric;
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_stopMetric(response.requestOptions, response.statusCode);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
_stopMetric(err.requestOptions, err.response?.statusCode);
handler.next(err);
}
void _stopMetric(RequestOptions options, int? statusCode) {
final metric = options.extra.remove(_metricKey) as HttpMetric?;
if (metric == null) return;
if (statusCode != null) metric.httpResponseCode = statusCode;
metric.stop();
}
static HttpMethod _getHttpMethod(String method) => switch (method.toUpperCase()) {
'GET' => HttpMethod.Get,
'POST' => HttpMethod.Post,
'PUT' => HttpMethod.Put,
'DELETE' => HttpMethod.Delete,
'PATCH' => HttpMethod.Patch,
_ => HttpMethod.Get,
};
}

View file

@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
import 'package:app_links/app_links.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -77,6 +79,19 @@ void main() async {
// Initialize a settings store from the settings JSON string.
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
// Disable Firebase collection in debug builds to avoid polluting production data.
// In release builds, respect the user's preference.
if (kDebugMode) {
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false);
FirebasePerformance.instance.setPerformanceCollectionEnabled(false);
} else {
final shareEnabled = settingsStore.shareCrashLogsAndAnalytics;
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled);
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled);
FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled);
}
// Create a MobX reaction that will save the settings on disk every time they are changed.
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
@ -108,6 +123,10 @@ void main() async {
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
await authStore.init();
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
if (authStore.isLoggedIn && authStore.user.details != null) {
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
}
runApp(
MultiProvider(
@ -173,6 +192,11 @@ class _MyAppState extends State<MyApp> {
: settingsStore.themeType == ThemeType.light
? ThemeMode.light
: ThemeMode.dark,
navigatorObservers: [
FirebaseAnalyticsObserver(
analytics: FirebaseAnalytics.instance,
),
],
home: widget.firstRun ? const OnboardingIntro() : const Home(),
navigatorKey: navigatorKey,
);
@ -227,6 +251,7 @@ class _MyAppState extends State<MyApp> {
final user = await twitchApi.getUser(userLogin: channelName);
final route = MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: user.id,
userName: user.displayName,

View file

@ -21,6 +21,8 @@ import 'package:simple_pip_mode/pip_widget.dart';
/// Creates a widget that shows the video stream (if live) and chat of the given user.
class VideoChat extends StatefulWidget {
static const routeName = 'VideoChat';
final String userId;
final String userName;
final String userLogin;

View file

@ -416,6 +416,7 @@ class _ChatDetailsState extends State<ChatDetails> {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: Settings.routeName),
builder: (context) =>
Settings(settingsStore: widget.chatStore.settings),
),

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart';
@ -862,7 +863,14 @@ abstract class ChatStoreBase with Store {
);
}
},
onError: (error) => debugPrint('7TV events error: ${error.toString()}'),
onError: (error) {
debugPrint('7TV events error: ${error.toString()}');
FirebaseCrashlytics.instance.recordError(
error,
StackTrace.current,
reason: '7TV WebSocket error, channel=$channelName',
);
},
onDone: () {
_clearPendingDelayedCallbacks(clearChat: false);
debugPrint('7TV events done');
@ -874,6 +882,8 @@ abstract class ChatStoreBase with Store {
@action
Future<void> connectToChat({bool isReconnect = false}) async {
FirebaseCrashlytics.instance.setCustomKey('channel', channelName);
// Fetch assets first so they're available for all messages
getAssets().catchError(
(e) => debugPrint('Failed to fetch chat assets: $e'),
@ -907,7 +917,14 @@ abstract class ChatStoreBase with Store {
});
}
},
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
onError: (error) {
debugPrint('Chat error: ${error.toString()}');
FirebaseCrashlytics.instance.recordError(
error,
StackTrace.current,
reason: 'IRC WebSocket error, channel=$channelName',
);
},
onDone: () async {
_clearPendingDelayedCallbacks(clearSevenTV: false);
@ -924,6 +941,9 @@ abstract class ChatStoreBase with Store {
}
if (_retries >= _maxRetries) {
FirebaseCrashlytics.instance.log(
'WebSocket reconnection failed after $_maxRetries attempts, channel=$channelName',
);
// Remove the reconnect message before showing final disconnect notice
if (_reconnectMessage != null) {
final index = _messages.indexOf(_reconnectMessage!);
@ -949,6 +969,9 @@ abstract class ChatStoreBase with Store {
// Increment the retry count.
_retries++;
FirebaseCrashlytics.instance.log(
'WebSocket reconnecting: attempt $_retries/$_maxRetries, channel=$channelName',
);
// Start exponential backoff only after first failed attempt (capped at 8s).
// First retry is immediate to catch brief network hiccups.

View file

@ -202,6 +202,7 @@ class StreamInfoBar extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CategoryStreams.routeName),
builder: (context) =>
CategoryStreams(
categoryId:
@ -293,6 +294,7 @@ class StreamInfoBar extends StatelessWidget {
onDoubleTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CategoryStreams.routeName),
builder: (context) => CategoryStreams(
categoryId:
streamInfo?.gameId ?? '',

View file

@ -44,7 +44,10 @@ class _HomeState extends State<Home> {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ReleaseNotes()),
MaterialPageRoute(
settings: const RouteSettings(name: ReleaseNotes.routeName),
builder: (context) => const ReleaseNotes(),
),
).then((_) => prefs.setString('last_shown_version', currentVersion));
}
}
@ -121,6 +124,7 @@ class _HomeState extends State<Home> {
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: Settings.routeName),
builder: (context) => Settings(
settingsStore: context.read<SettingsStore>(),
),

View file

@ -39,6 +39,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: channelInfo.broadcasterId,
userName: channelInfo.broadcasterName,
@ -120,6 +121,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: channel.id,
userName: channel.displayName,

View file

@ -75,6 +75,7 @@ class LargeStreamCard extends StatelessWidget {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: streamInfo.userId,
userName: streamInfo.userName,

View file

@ -57,6 +57,7 @@ class OfflineChannelCard extends StatelessWidget {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: channelInfo.broadcasterId,
userName: channelInfo.broadcasterName,

View file

@ -168,6 +168,7 @@ class StreamCard extends StatelessWidget {
? () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CategoryStreams.routeName),
builder: (context) =>
CategoryStreams(categoryId: streamInfo.gameId),
),
@ -203,6 +204,7 @@ class StreamCard extends StatelessWidget {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoChat.routeName),
builder: (context) => VideoChat(
userId: streamInfo.userId,
userName: streamInfo.userName,

View file

@ -29,6 +29,7 @@ class CategoryCard extends StatelessWidget {
? () => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CategoryStreams.routeName),
builder: (context) => CategoryStreams(categoryId: category.id),
),
)

View file

@ -13,6 +13,8 @@ import 'package:provider/provider.dart';
/// A widget that displays a list of streams under the provided [categoryId].
class CategoryStreams extends StatefulWidget {
static const routeName = 'CategoryStreams';
/// The category id, used for fetching the relevant streams in the [ListStore].
final String categoryId;

View file

@ -65,7 +65,10 @@ class _OtherSettingsState extends State<OtherSettings> {
title: const Text('Release notes'),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => const ReleaseNotes())),
).push(MaterialPageRoute(
settings: const RouteSettings(name: ReleaseNotes.routeName),
builder: (context) => const ReleaseNotes(),
)),
),
ListTile(
leading: const Icon(Icons.delete_outline_rounded),

View file

@ -17,6 +17,8 @@ import 'package:simple_icons/simple_icons.dart';
import 'package:url_launcher/url_launcher.dart';
class Settings extends StatelessWidget {
static const routeName = 'Settings';
final SettingsStore settingsStore;
const Settings({super.key, required this.settingsStore});

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:frosty/apis/base_api_client.dart';
@ -250,6 +251,7 @@ abstract class AuthBase with Store {
}
}
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', _isLoggedIn);
_error = null;
} catch (e) {
debugPrint(e.toString());
@ -277,6 +279,8 @@ abstract class AuthBase with Store {
// Set the login status to logged in.
if (user.details != null) {
_isLoggedIn = true;
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true);
FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id);
_stopReconnectLoop();
}
} catch (e) {
@ -307,6 +311,8 @@ abstract class AuthBase with Store {
// Set the login status to logged out.
_isLoggedIn = false;
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', false);
FirebaseCrashlytics.instance.setUserIdentifier('');
debugPrint('Successfully logged out');
} catch (e) {
@ -321,6 +327,9 @@ abstract class AuthBase with Store {
try {
_reconnectAttempts++;
if (_reconnectAttempts > _maxReconnectAttempts) {
FirebaseCrashlytics.instance.log(
'Auth reconnection exhausted after $_maxReconnectAttempts attempts',
);
await logout();
return;
}

View file

@ -8,6 +8,8 @@ import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ReleaseNotes extends StatefulWidget {
static const routeName = 'ReleaseNotes';
const ReleaseNotes({super.key});
@override

View file

@ -24,6 +24,7 @@ class SettingsTileRoute extends StatelessWidget {
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
settings: RouteSettings(name: title),
builder: (context) => useScaffold
? _BlurredSettingsPage(title: title, child: child)
: child,

View file

@ -1,3 +1,4 @@
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:frosty/apis/bttv_api.dart';
import 'package:frosty/apis/ffz_api.dart';
@ -166,10 +167,13 @@ abstract class GlobalAssetsStoreBase with Store {
required bool showFFZEmotes,
required bool showFFZBadges,
}) async {
// Error handler for emotes
List<Emote> onEmoteError(dynamic error) {
debugPrint('GlobalAssetsStore emote error: $error');
return <Emote>[];
void onAssetError(dynamic error, StackTrace stack) {
debugPrint('GlobalAssetsStore error: $error');
FirebaseCrashlytics.instance.recordError(
error,
stack,
reason: 'Global asset fetch failed',
);
}
await Future.wait([
@ -178,8 +182,9 @@ abstract class GlobalAssetsStoreBase with Store {
twitchApi
.getEmotesGlobal()
.then((emotes) => _twitchGlobalEmotes = emotes)
.catchError((e) {
_twitchGlobalEmotes = onEmoteError(e);
.catchError((e, StackTrace stack) {
onAssetError(e, stack);
_twitchGlobalEmotes = <Emote>[];
return _twitchGlobalEmotes;
}),
// 7TV global emotes
@ -187,8 +192,9 @@ abstract class GlobalAssetsStoreBase with Store {
sevenTVApi
.getEmotesGlobal()
.then((emotes) => _sevenTVGlobalEmotes = emotes)
.catchError((e) {
_sevenTVGlobalEmotes = onEmoteError(e);
.catchError((e, StackTrace stack) {
onAssetError(e, stack);
_sevenTVGlobalEmotes = <Emote>[];
return _sevenTVGlobalEmotes;
}),
// BTTV global emotes
@ -196,8 +202,9 @@ abstract class GlobalAssetsStoreBase with Store {
bttvApi
.getEmotesGlobal()
.then((emotes) => _bttvGlobalEmotes = emotes)
.catchError((e) {
_bttvGlobalEmotes = onEmoteError(e);
.catchError((e, StackTrace stack) {
onAssetError(e, stack);
_bttvGlobalEmotes = <Emote>[];
return _bttvGlobalEmotes;
}),
// FFZ global emotes
@ -205,8 +212,9 @@ abstract class GlobalAssetsStoreBase with Store {
ffzApi
.getEmotesGlobal()
.then((emotes) => _ffzGlobalEmotes = emotes)
.catchError((e) {
_ffzGlobalEmotes = onEmoteError(e);
.catchError((e, StackTrace stack) {
onAssetError(e, stack);
_ffzGlobalEmotes = <Emote>[];
return _ffzGlobalEmotes;
}),
@ -215,8 +223,8 @@ abstract class GlobalAssetsStoreBase with Store {
twitchApi
.getBadgesGlobal()
.then((badges) => _twitchGlobalBadges = badges)
.catchError((e) {
debugPrint('GlobalAssetsStore badge error: $e');
.catchError((e, StackTrace stack) {
onAssetError(e, stack);
_twitchGlobalBadges = <String, ChatBadge>{};
return _twitchGlobalBadges;
}),
@ -224,8 +232,9 @@ abstract class GlobalAssetsStoreBase with Store {
if (showBTTVBadges)
bttvApi.getBadges().then((badges) => _bttvBadges = badges).catchError((
e,
StackTrace stack,
) {
debugPrint('GlobalAssetsStore badge error: $e');
onAssetError(e, stack);
_bttvBadges = <String, ChatBadge>{};
return _bttvBadges;
}),
@ -233,8 +242,9 @@ abstract class GlobalAssetsStoreBase with Store {
if (showFFZBadges)
ffzApi.getBadges().then((badges) => _ffzBadges = badges).catchError((
e,
StackTrace stack,
) {
debugPrint('GlobalAssetsStore badge error: $e');
onAssetError(e, stack);
_ffzBadges = <String, List<ChatBadge>>{};
return _ffzBadges;
}),