Skip to content

Commit 6ede000

Browse files
tommyxchowclaude
andcommitted
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>
1 parent f07fe62 commit 6ede000

21 files changed

+192
-42
lines changed

lib/apis/base_api_client.dart

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:dio/dio.dart';
2+
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
23

34
/// Common type aliases to reduce repetition
45
typedef JsonMap = Map<String, dynamic>;
@@ -157,29 +158,35 @@ abstract class BaseApiClient {
157158

158159
/// Converts DioException to appropriate ApiException subtype
159160
ApiException _handleError(DioException error) {
160-
switch (error.type) {
161-
case DioExceptionType.connectionTimeout:
162-
case DioExceptionType.sendTimeout:
163-
case DioExceptionType.receiveTimeout:
164-
return TimeoutException(_getTimeoutMessage(error.type));
165-
166-
case DioExceptionType.connectionError:
167-
return NetworkException(
168-
'No internet connection. Please check your network.',
169-
);
170-
171-
case DioExceptionType.badResponse:
172-
return _handleHttpError(error);
173-
174-
case DioExceptionType.cancel:
175-
return ApiException('Request was cancelled');
176-
177-
case DioExceptionType.badCertificate:
178-
return ApiException('Security certificate error');
179-
180-
case DioExceptionType.unknown:
181-
return ApiException(error.message ?? 'An unexpected error occurred');
161+
final exception = switch (error.type) {
162+
DioExceptionType.connectionTimeout ||
163+
DioExceptionType.sendTimeout ||
164+
DioExceptionType.receiveTimeout =>
165+
TimeoutException(_getTimeoutMessage(error.type)),
166+
DioExceptionType.connectionError => NetworkException(
167+
'No internet connection. Please check your network.',
168+
),
169+
DioExceptionType.badResponse => _handleHttpError(error),
170+
DioExceptionType.cancel => ApiException('Request was cancelled'),
171+
DioExceptionType.badCertificate =>
172+
ApiException('Security certificate error'),
173+
DioExceptionType.unknown =>
174+
ApiException(error.message ?? 'An unexpected error occurred'),
175+
};
176+
177+
// Record server errors and unexpected failures to Crashlytics.
178+
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
179+
if (exception is ServerException ||
180+
error.type == DioExceptionType.unknown ||
181+
error.type == DioExceptionType.badCertificate) {
182+
FirebaseCrashlytics.instance.recordError(
183+
exception,
184+
error.stackTrace,
185+
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
186+
);
182187
}
188+
189+
return exception;
183190
}
184191

185192
/// Gets specific timeout message based on timeout type

lib/apis/dio_client.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:dio/dio.dart';
22
import 'package:flutter/foundation.dart';
3+
import 'package:frosty/apis/firebase_performance_interceptor.dart';
34

45
/// Centralized Dio client configuration with interceptors and error handling
56
class DioClient {
@@ -55,6 +56,9 @@ class DioClient {
5556
);
5657
}
5758

59+
// Trace HTTP requests as Firebase Performance metrics
60+
dio.interceptors.add(FirebasePerformanceInterceptor());
61+
5862
// Add any additional interceptors provided
5963
if (additionalInterceptors != null) {
6064
for (final interceptor in additionalInterceptors) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import 'package:dio/dio.dart';
2+
import 'package:firebase_performance/firebase_performance.dart';
3+
4+
/// Dio interceptor that traces HTTP requests as Firebase Performance metrics.
5+
class FirebasePerformanceInterceptor extends Interceptor {
6+
static const _metricKey = 'firebase_performance_metric';
7+
8+
@override
9+
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
10+
final metric = FirebasePerformance.instance.newHttpMetric(
11+
options.uri.toString(),
12+
_getHttpMethod(options.method),
13+
);
14+
metric.start();
15+
options.extra[_metricKey] = metric;
16+
handler.next(options);
17+
}
18+
19+
@override
20+
void onResponse(Response response, ResponseInterceptorHandler handler) {
21+
_stopMetric(response.requestOptions, response.statusCode);
22+
handler.next(response);
23+
}
24+
25+
@override
26+
void onError(DioException err, ErrorInterceptorHandler handler) {
27+
_stopMetric(err.requestOptions, err.response?.statusCode);
28+
handler.next(err);
29+
}
30+
31+
void _stopMetric(RequestOptions options, int? statusCode) {
32+
final metric = options.extra.remove(_metricKey) as HttpMetric?;
33+
if (metric == null) return;
34+
if (statusCode != null) metric.httpResponseCode = statusCode;
35+
metric.stop();
36+
}
37+
38+
static HttpMethod _getHttpMethod(String method) => switch (method.toUpperCase()) {
39+
'GET' => HttpMethod.Get,
40+
'POST' => HttpMethod.Post,
41+
'PUT' => HttpMethod.Put,
42+
'DELETE' => HttpMethod.Delete,
43+
'PATCH' => HttpMethod.Patch,
44+
_ => HttpMethod.Get,
45+
};
46+
}

lib/main.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import 'dart:convert';
33

44
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
55
import 'package:app_links/app_links.dart';
6+
import 'package:firebase_analytics/firebase_analytics.dart';
67
import 'package:firebase_core/firebase_core.dart';
78
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
9+
import 'package:firebase_performance/firebase_performance.dart';
810
import 'package:flutter/foundation.dart';
911
import 'package:flutter/material.dart';
1012
import 'package:flutter_mobx/flutter_mobx.dart';
@@ -77,6 +79,19 @@ void main() async {
7779
// Initialize a settings store from the settings JSON string.
7880
final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings));
7981

82+
// Disable Firebase collection in debug builds to avoid polluting production data.
83+
// In release builds, respect the user's preference.
84+
if (kDebugMode) {
85+
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false);
86+
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false);
87+
FirebasePerformance.instance.setPerformanceCollectionEnabled(false);
88+
} else {
89+
final shareEnabled = settingsStore.shareCrashLogsAndAnalytics;
90+
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled);
91+
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled);
92+
FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled);
93+
}
94+
8095
// Create a MobX reaction that will save the settings on disk every time they are changed.
8196
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
8297

@@ -108,6 +123,10 @@ void main() async {
108123
dioClient.interceptors.add(UnauthorizedInterceptor(authStore));
109124

110125
await authStore.init();
126+
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn);
127+
if (authStore.isLoggedIn && authStore.user.details != null) {
128+
FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id);
129+
}
111130

112131
runApp(
113132
MultiProvider(
@@ -173,6 +192,11 @@ class _MyAppState extends State<MyApp> {
173192
: settingsStore.themeType == ThemeType.light
174193
? ThemeMode.light
175194
: ThemeMode.dark,
195+
navigatorObservers: [
196+
FirebaseAnalyticsObserver(
197+
analytics: FirebaseAnalytics.instance,
198+
),
199+
],
176200
home: widget.firstRun ? const OnboardingIntro() : const Home(),
177201
navigatorKey: navigatorKey,
178202
);
@@ -227,6 +251,7 @@ class _MyAppState extends State<MyApp> {
227251
final user = await twitchApi.getUser(userLogin: channelName);
228252

229253
final route = MaterialPageRoute(
254+
settings: const RouteSettings(name: VideoChat.routeName),
230255
builder: (context) => VideoChat(
231256
userId: user.id,
232257
userName: user.displayName,

lib/screens/channel/channel.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import 'package:simple_pip_mode/pip_widget.dart';
2121

2222
/// Creates a widget that shows the video stream (if live) and chat of the given user.
2323
class VideoChat extends StatefulWidget {
24+
static const routeName = 'VideoChat';
25+
2426
final String userId;
2527
final String userName;
2628
final String userLogin;

lib/screens/channel/chat/details/chat_details.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ class _ChatDetailsState extends State<ChatDetails> {
416416
onTap: () => Navigator.push(
417417
context,
418418
MaterialPageRoute(
419+
settings: const RouteSettings(name: Settings.routeName),
419420
builder: (context) =>
420421
Settings(settingsStore: widget.chatStore.settings),
421422
),

lib/screens/channel/chat/stores/chat_store.dart

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22
import 'dart:convert';
33

4+
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
45
import 'package:flutter/material.dart';
56
import 'package:flutter/services.dart';
67
import 'package:frosty/apis/twitch_api.dart';
@@ -862,7 +863,14 @@ abstract class ChatStoreBase with Store {
862863
);
863864
}
864865
},
865-
onError: (error) => debugPrint('7TV events error: ${error.toString()}'),
866+
onError: (error) {
867+
debugPrint('7TV events error: ${error.toString()}');
868+
FirebaseCrashlytics.instance.recordError(
869+
error,
870+
StackTrace.current,
871+
reason: '7TV WebSocket error, channel=$channelName',
872+
);
873+
},
866874
onDone: () {
867875
_clearPendingDelayedCallbacks(clearChat: false);
868876
debugPrint('7TV events done');
@@ -874,6 +882,8 @@ abstract class ChatStoreBase with Store {
874882

875883
@action
876884
Future<void> connectToChat({bool isReconnect = false}) async {
885+
FirebaseCrashlytics.instance.setCustomKey('channel', channelName);
886+
877887
// Fetch assets first so they're available for all messages
878888
getAssets().catchError(
879889
(e) => debugPrint('Failed to fetch chat assets: $e'),
@@ -907,7 +917,14 @@ abstract class ChatStoreBase with Store {
907917
});
908918
}
909919
},
910-
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
920+
onError: (error) {
921+
debugPrint('Chat error: ${error.toString()}');
922+
FirebaseCrashlytics.instance.recordError(
923+
error,
924+
StackTrace.current,
925+
reason: 'IRC WebSocket error, channel=$channelName',
926+
);
927+
},
911928
onDone: () async {
912929
_clearPendingDelayedCallbacks(clearSevenTV: false);
913930

@@ -924,6 +941,9 @@ abstract class ChatStoreBase with Store {
924941
}
925942

926943
if (_retries >= _maxRetries) {
944+
FirebaseCrashlytics.instance.log(
945+
'WebSocket reconnection failed after $_maxRetries attempts, channel=$channelName',
946+
);
927947
// Remove the reconnect message before showing final disconnect notice
928948
if (_reconnectMessage != null) {
929949
final index = _messages.indexOf(_reconnectMessage!);
@@ -949,6 +969,9 @@ abstract class ChatStoreBase with Store {
949969

950970
// Increment the retry count.
951971
_retries++;
972+
FirebaseCrashlytics.instance.log(
973+
'WebSocket reconnecting: attempt $_retries/$_maxRetries, channel=$channelName',
974+
);
952975

953976
// Start exponential backoff only after first failed attempt (capped at 8s).
954977
// First retry is immediate to catch brief network hiccups.

lib/screens/channel/video/stream_info_bar.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ class StreamInfoBar extends StatelessWidget {
202202
Navigator.push(
203203
context,
204204
MaterialPageRoute(
205+
settings: const RouteSettings(name: CategoryStreams.routeName),
205206
builder: (context) =>
206207
CategoryStreams(
207208
categoryId:
@@ -293,6 +294,7 @@ class StreamInfoBar extends StatelessWidget {
293294
onDoubleTap: () => Navigator.push(
294295
context,
295296
MaterialPageRoute(
297+
settings: const RouteSettings(name: CategoryStreams.routeName),
296298
builder: (context) => CategoryStreams(
297299
categoryId:
298300
streamInfo?.gameId ?? '',

lib/screens/home/home.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ class _HomeState extends State<Home> {
4444

4545
Navigator.push(
4646
context,
47-
MaterialPageRoute(builder: (context) => const ReleaseNotes()),
47+
MaterialPageRoute(
48+
settings: const RouteSettings(name: ReleaseNotes.routeName),
49+
builder: (context) => const ReleaseNotes(),
50+
),
4851
).then((_) => prefs.setString('last_shown_version', currentVersion));
4952
}
5053
}
@@ -121,6 +124,7 @@ class _HomeState extends State<Home> {
121124
onPressed: () => Navigator.push(
122125
context,
123126
MaterialPageRoute(
127+
settings: const RouteSettings(name: Settings.routeName),
124128
builder: (context) => Settings(
125129
settingsStore: context.read<SettingsStore>(),
126130
),

lib/screens/home/search/search_results_channels.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
3939
Navigator.push(
4040
context,
4141
MaterialPageRoute(
42+
settings: const RouteSettings(name: VideoChat.routeName),
4243
builder: (context) => VideoChat(
4344
userId: channelInfo.broadcasterId,
4445
userName: channelInfo.broadcasterName,
@@ -120,6 +121,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
120121
onTap: () => Navigator.push(
121122
context,
122123
MaterialPageRoute(
124+
settings: const RouteSettings(name: VideoChat.routeName),
123125
builder: (context) => VideoChat(
124126
userId: channel.id,
125127
userName: channel.displayName,

0 commit comments

Comments
 (0)