feat: 添加 Analytics 分析功能(行为追踪、错误码、协议更新)

This commit is contained in:
qzl
2026-04-02 11:52:23 +08:00
parent b101826de5
commit 7b6dbe72c3
24 changed files with 682 additions and 52 deletions
@@ -79,6 +79,26 @@ String? mapErrorCodeToL10nKey(
return 'errorNotFound';
case 'AUTH_UNAUTHORIZED':
return 'errorReLogin';
case 'ANALYTICS_LOGIN_PASSWORD_INVALID':
return 'errorGenericSafe';
case 'ANALYTICS_AUTH_HEADER_MISSING':
return 'errorReLogin';
case 'ANALYTICS_AUTH_SCHEME_INVALID':
return 'errorReLogin';
case 'ANALYTICS_AUTH_TOKEN_MISSING':
return 'errorReLogin';
case 'ANALYTICS_TOKEN_MALFORMED':
return 'errorReLogin';
case 'ANALYTICS_TOKEN_SIGNATURE_INVALID':
return 'errorReLogin';
case 'ANALYTICS_TOKEN_PAYLOAD_INVALID':
return 'errorReLogin';
case 'ANALYTICS_TOKEN_EXPIRED':
return 'errorReLogin';
case 'ANALYTICS_DATE_FORMAT_INVALID':
return 'errorGenericSafe';
case 'ANALYTICS_FILE_NOT_FOUND':
return 'errorNotFound';
case 'JWT_VERIFIER_NOT_CONFIGURED':
return 'errorServer';
case 'AUTOMATION_JOB_LIMIT_EXCEEDED':
@@ -12,6 +12,7 @@ import 'package:social_app/core/chat/chat_list_item.dart';
import 'package:social_app/core/chat/chat_orchestrator.dart';
import 'package:social_app/core/chat/chat_history_repository.dart';
import 'package:social_app/core/chat/chat_timeline_reconciler.dart';
import 'package:social_app/core/analytics/tracker.dart';
import 'package:social_app/core/l10n/l10n.dart';
import 'chat_bloc_recovery_utils.dart';
@@ -20,6 +21,13 @@ part 'chat_bloc_send.dart';
part 'chat_bloc_history.dart';
part 'chat_bloc_attachments.dart';
typedef ChatCompletedCallback =
void Function({
required String conversationId,
required int messageCount,
required int responseTimeMs,
});
class ChatState implements ChatOrchestratorState {
@override
final List<ChatListItem> items;
@@ -115,12 +123,14 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
required ChatApi chatApi,
ChatHistoryRepository? historyRepository,
Future<void> Function()? onCalendarMutated,
ChatCompletedCallback? onChatCompleted,
Duration recoveryPollInterval = const Duration(milliseconds: 700),
Duration recoveryTimeout = const Duration(seconds: 20),
}) : _service =
service ??
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
_onCalendarMutated = onCalendarMutated,
_onChatCompleted = onChatCompleted,
_recoveryPollInterval = recoveryPollInterval,
_recoveryTimeout = recoveryTimeout,
super(const ChatState()) {
@@ -129,9 +139,14 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
final AgUiService _service;
final Future<void> Function()? _onCalendarMutated;
final ChatCompletedCallback? _onChatCompleted;
final Duration _recoveryPollInterval;
final Duration _recoveryTimeout;
String? _activeUserId;
DateTime? _activeRunStartedAt;
DateTime? _activeRunFirstResponseAt;
String? _activeRunId;
String? _activeThreadId;
int _sessionEpoch = 0;
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
@@ -259,4 +274,54 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
emit(state.copyWith(error: error.toString()));
}
}
void _recordRunStarted({required String runId, required String threadId}) {
_activeRunStartedAt = DateTime.now();
_activeRunFirstResponseAt = null;
_activeRunId = runId;
_activeThreadId = threadId;
}
void _recordRunFirstResponse() {
_activeRunFirstResponseAt ??= DateTime.now();
}
void _trackChatCompleted() {
final startedAt = _activeRunStartedAt;
if (startedAt == null) {
return;
}
final firstResponseAt = _activeRunFirstResponseAt ?? DateTime.now();
final responseTimeMs = firstResponseAt.difference(startedAt).inMilliseconds;
final threadId = _activeThreadId?.trim();
final runId = _activeRunId?.trim();
final conversationId = (threadId != null && threadId.isNotEmpty)
? threadId
: runId;
if (conversationId == null || conversationId.isEmpty) {
return;
}
final onChatCompleted = _onChatCompleted;
if (onChatCompleted != null) {
onChatCompleted(
conversationId: conversationId,
messageCount: 1,
responseTimeMs: responseTimeMs,
);
return;
}
AnalyticsTracker.instance.trackAgentChatCompleted(
conversationId: conversationId,
scenario: 'assistant',
messageCount: 1,
responseTimeMs: responseTimeMs,
);
}
void _clearRunMetrics() {
_activeRunStartedAt = null;
_activeRunFirstResponseAt = null;
_activeRunId = null;
_activeThreadId = null;
}
}
@@ -6,6 +6,11 @@ extension _ChatBlocEvents on ChatBloc {
void _handleEvent(AgUiEvent event) {
switch (event.type) {
case AgUiEventType.runStarted:
final runStartedEvent = event as RunStartedEvent;
_recordRunStarted(
runId: runStartedEvent.runId,
threadId: runStartedEvent.threadId,
);
emit(
state.copyWith(
isSending: false,
@@ -17,11 +22,14 @@ extension _ChatBlocEvents on ChatBloc {
),
);
case AgUiEventType.runFinished:
_trackChatCompleted();
_clearRunMetrics();
emit(
_resetRunState().copyWith(items: _removeToolCallItems(state.items)),
);
case AgUiEventType.runError:
final errorEvent = event as RunErrorEvent;
_clearRunMetrics();
final isCanceledByUser = errorEvent.code == 'RUN_CANCELED';
emit(
_resetRunState(
@@ -72,6 +80,7 @@ extension _ChatBlocEvents on ChatBloc {
}
void _handleTextMessageEnd(TextMessageEndEvent event) {
_recordRunFirstResponse();
final timestamp = DateTime.now();
final items = _updateOrAddMessage(
state.items,
@@ -12,6 +12,7 @@ import '../../../../app/di/injection.dart';
import '../../../../app/router/app_route_observer.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/analytics/tracker.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../core/inbox/inbox_sync_store.dart';
import '../../../chat/presentation/bloc/chat_bloc.dart';
@@ -98,6 +99,8 @@ class _HomeScreenState extends State<HomeScreen>
int _previousItemCount = 0;
bool _previousIsLoadingHistory = false;
bool _routeAwareSubscribed = false;
late final DateTime _pageEnteredAt;
int _pageClickCount = 0;
double? _historyViewportPixels;
double? _historyViewportMaxExtent;
final GlobalKey<HomeInputHostState> _inputHostKey =
@@ -121,6 +124,7 @@ class _HomeScreenState extends State<HomeScreen>
duration: const Duration(milliseconds: _rippleDurationMs),
);
_selectedImages.addAll(widget.initialSelectedImages);
_pageEnteredAt = DateTime.now();
final initialUserId = widget.initialUserId?.trim();
if (initialUserId != null && initialUserId.isNotEmpty) {
unawaited(_chatBloc.switchUser(initialUserId));
@@ -148,6 +152,14 @@ class _HomeScreenState extends State<HomeScreen>
@override
void dispose() {
final stayDurationMs = DateTime.now()
.difference(_pageEnteredAt)
.inMilliseconds;
AnalyticsTracker.instance.trackPageView(
pageName: 'home',
stayDurationMs: stayDurationMs,
clickCount: _pageClickCount,
);
_messageController.dispose();
_scrollController.removeListener(_handleScrollChanged);
_scrollController.dispose();
@@ -281,10 +293,18 @@ class _HomeScreenState extends State<HomeScreen>
Widget _buildHeader(BuildContext context) {
return HomeFloatingHeader(
unreadCount: _unreadCount,
onTapSettings: () => context.push(AppRoutes.settingsMain),
onTapCalendar: () =>
context.push('${AppRoutes.calendarDayWeek}?from=home'),
onTapMessages: () => context.push(AppRoutes.messageInviteList),
onTapSettings: () {
_trackClick('header_settings');
context.push(AppRoutes.settingsMain);
},
onTapCalendar: () {
_trackClick('header_calendar');
context.push('${AppRoutes.calendarDayWeek}?from=home');
},
onTapMessages: () {
_trackClick('header_messages');
context.push(AppRoutes.messageInviteList);
},
);
}
@@ -386,6 +406,7 @@ class _HomeScreenState extends State<HomeScreen>
child: HomeUnreadBadge(
count: _chatUnreadBadgeCount,
onTap: () {
_trackClick('unread_badge');
_scheduleAutoScroll(animated: true);
if (mounted) {
setState(() => _chatUnreadBadgeCount = 0);
@@ -438,6 +459,7 @@ class _HomeScreenState extends State<HomeScreen>
}
Future<void> _onLoadMore(BuildContext context) async {
_trackClick('history_load_more');
final chatBloc = context.read<ChatBloc>();
await _loadMoreHistoryPreservingViewport(chatBloc);
}
@@ -650,9 +672,18 @@ class _HomeScreenState extends State<HomeScreen>
isWaitingAgent: isWaitingAgent,
messageController: _messageController,
onTapPlus: _isRecording
? () => _stopRecording(autoSendAfterTranscribe: false)
: () => _showBottomSheet(context),
onStopGenerating: _onStopGenerating,
? () {
_trackClick('record_stop');
_stopRecording(autoSendAfterTranscribe: false);
}
: () {
_trackClick('input_plus');
_showBottomSheet(context);
},
onStopGenerating: () {
_trackClick('stop_generating');
_onStopGenerating();
},
onHoldToSpeakStart: _onHoldToSpeakStart,
onHoldToSpeakEnd: _onHoldToSpeakEnd,
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
@@ -662,6 +693,15 @@ class _HomeScreenState extends State<HomeScreen>
);
}
void _trackClick(String elementId) {
_pageClickCount += 1;
AnalyticsTracker.instance.trackClick(
pageName: 'home',
elementId: elementId,
elementType: 'button',
);
}
void _removeImage(int index) {
setState(() {
_selectedImages.removeAt(index);
@@ -53,6 +53,7 @@ extension _HomeScreenInteractions on _HomeScreenState {
});
try {
_trackClick('send_message');
await _chatBloc.sendMessage(content, images: images);
} finally {
if (mounted) {
+1 -1
View File
@@ -1,7 +1,7 @@
name: social_app
description: "Social App - A Flutter mobile application"
publish_to: 'none'
version: 0.1.2+5
version: 0.1.2+7
environment:
sdk: ^3.10.7
@@ -385,4 +385,86 @@ void main() {
expect(bloc.state.currentStage, isNull);
},
);
test('chat completed analytics triggers once only on RUN_FINISHED', () async {
final service = _FakeAgUiService();
var completedCalls = 0;
String? lastConversationId;
int? lastMessageCount;
int? lastResponseTimeMs;
final bloc = ChatBloc(
service: service,
chatApi: _NoopChatApi(),
onChatCompleted:
({
required String conversationId,
required int messageCount,
required int responseTimeMs,
}) {
completedCalls += 1;
lastConversationId = conversationId;
lastMessageCount = messageCount;
lastResponseTimeMs = responseTimeMs;
},
);
service.emitEventForTest(
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
);
service.emitEventForTest(
TextMessageEndEvent(
messageId: 'msg-1',
answer: 'hello',
role: 'assistant',
status: 'success',
uiSchema: null,
),
);
expect(completedCalls, 0);
service.emitEventForTest(
RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'),
);
service.emitEventForTest(
RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'),
);
expect(completedCalls, 1);
expect(lastConversationId, 'thread-1');
expect(lastMessageCount, 1);
expect(lastResponseTimeMs, isNotNull);
expect(lastResponseTimeMs, greaterThanOrEqualTo(0));
bloc.close();
});
test('chat completed analytics does not trigger on RUN_ERROR', () async {
final service = _FakeAgUiService();
var completedCalls = 0;
final bloc = ChatBloc(
service: service,
chatApi: _NoopChatApi(),
onChatCompleted:
({
required String conversationId,
required int messageCount,
required int responseTimeMs,
}) {
completedCalls += 1;
},
);
service.emitEventForTest(
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
);
service.emitEventForTest(
RunErrorEvent(message: 'run failed', code: 'RUN_FAILED'),
);
expect(completedCalls, 0);
bloc.close();
});
}