From 9b760c56ee36c80e4e1a7425374aa6e6764ccffe Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 09:39:48 -0800 Subject: [PATCH] feat(mobile/telemetry): add comprehensive event tracking for DAU, real-time presence & funnels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 架构设计(亿级用户规模) - 客户端:本地队列(500条) + 批量上传(20条/30s) + 10% 采样率 - 心跳:前台每60s发送,后端3分钟滑动窗口 → 实时在线率 - DAU:app_session_start 事件 + installId 去重 - 后端接入:Kafka → ClickHouse 消费落盘 ## 新增 TelemetryRouteObserver (NEW FILE) - 继承 RouteObserver,注册到 MaterialApp.navigatorObservers - 自动记录所有页面 page_view(push/pop/replace),零侵入业务页面 - 属性:page_name, previous_page, nav_action - 覆盖全部 37 个已注册路由,无需逐页埋点 ## main.dart - 接入 TelemetryRouteObserver.instance - 新增 FlutterError.onError 全局钩子 → logError 上报 Widget build 异常 ## auth_service.dart — 认证漏斗 | 事件 | 触发时机 | method 属性 | |------|---------|------------| | register_success | 注册成功 | phone_sms / email_code | | login_success | 登录成功 | password / phone_sms / email_code / wechat / alipay / google / apple | | user_logout | 主动登出 | — | ## self_hosted_updater.dart — 升级漏斗 update_prompted → update_accepted / update_dismissed → update_download_started → update_download_completed / update_download_failed / update_download_cancelled → update_install_triggered / update_install_failed Co-Authored-By: Claude Sonnet 4.6 --- .../lib/core/services/auth_service.dart | 29 ++++++ .../telemetry/telemetry_route_observer.dart | 93 +++++++++++++++++++ .../updater/channels/self_hosted_updater.dart | 60 +++++++++++- frontend/genex-mobile/lib/main.dart | 19 ++++ 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 frontend/genex-mobile/lib/core/telemetry/telemetry_route_observer.dart diff --git a/frontend/genex-mobile/lib/core/services/auth_service.dart b/frontend/genex-mobile/lib/core/services/auth_service.dart index 535692a..9490ab9 100644 --- a/frontend/genex-mobile/lib/core/services/auth_service.dart +++ b/frontend/genex-mobile/lib/core/services/auth_service.dart @@ -212,6 +212,10 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('register_success', properties: { + 'method': 'email_code', + 'has_referral': referralCode != null && referralCode.isNotEmpty, + }); return result; } @@ -230,6 +234,7 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('login_success', properties: {'method': 'email_code'}); return result; } @@ -252,6 +257,10 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('login_success', properties: { + 'method': 'wechat', + 'has_referral': referralCode != null && referralCode.isNotEmpty, + }); return result; } @@ -285,6 +294,10 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('login_success', properties: { + 'method': 'alipay', + 'has_referral': referralCode != null && referralCode.isNotEmpty, + }); return result; } @@ -306,6 +319,10 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('login_success', properties: { + 'method': 'google', + 'has_referral': referralCode != null && referralCode.isNotEmpty, + }); return result; } @@ -330,6 +347,10 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('login_success', properties: { + 'method': 'apple', + 'has_referral': referralCode != null && referralCode.isNotEmpty, + }); return result; } @@ -386,6 +407,10 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('register_success', properties: { + 'method': 'phone_sms', + 'has_referral': referralCode != null && referralCode.isNotEmpty, + }); return result; } @@ -406,6 +431,7 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('login_success', properties: {'method': 'password'}); return result; } @@ -422,6 +448,7 @@ class AuthService { }); final result = AuthResult.fromJson(resp.data['data']); await _setAuth(result); + TelemetryService().logUserAction('login_success', properties: {'method': 'phone_sms'}); return result; } @@ -495,6 +522,8 @@ class AuthService { /// /// 通知后端撤销 Token(fire-and-forget,即使失败也清除本地状态)。 Future logout() async { + // 登出前先上报事件(clearAuth 会清空 userId,需在之前记录) + TelemetryService().logUserAction('user_logout'); try { await _api.post('/api/v1/auth/logout'); } catch (_) { diff --git a/frontend/genex-mobile/lib/core/telemetry/telemetry_route_observer.dart b/frontend/genex-mobile/lib/core/telemetry/telemetry_route_observer.dart new file mode 100644 index 0000000..3b44b6c --- /dev/null +++ b/frontend/genex-mobile/lib/core/telemetry/telemetry_route_observer.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'telemetry_service.dart'; + +/// 路由观察器 — 自动记录所有页面访问事件(page_view) +/// +/// 接入:在 MaterialApp 的 navigatorObservers 中注册: +/// navigatorObservers: [TelemetryRouteObserver.instance] +/// +/// 支持命名路由(pushNamed)和匿名路由(builder: ...): +/// - 命名路由:记录 route.settings.name(如 '/coupon/detail') +/// - 匿名路由:记录 runtimeType.toString()(如 'MaterialPageRoute'), +/// 建议业务路由统一用 settings: RouteSettings(name: '/xxx') 命名 +/// +/// 亿级用户规模说明: +/// - page_view 受 TelemetryConfig.pageViewEnabled + samplingRate(默认10%)控制 +/// - 本地队列上限 500 条,溢出丢弃最旧事件,不阻塞 UI 线程 +/// - 批量 20 条 / 30 秒上传,后端可接入 Kafka → ClickHouse 消费 +class TelemetryRouteObserver extends RouteObserver> { + /// 单例:所有 Navigator 共享同一个观察器实例 + static final TelemetryRouteObserver instance = TelemetryRouteObserver._(); + + TelemetryRouteObserver._(); + + // ───────────────────────────────────────────────────────────────────────── + // 内部工具 + // ───────────────────────────────────────────────────────────────────────── + + /// 从路由中提取可读的页面名称 + /// + /// 优先级:settings.name → runtimeType + String _resolvePageName(Route? route) { + if (route == null) return 'unknown'; + final name = route.settings.name; + if (name != null && name.isNotEmpty) return name; + return route.runtimeType.toString(); + } + + /// 上报 page_view 事件 + void _track( + String pageName, { + String? previousPage, + required String navAction, // push | pop | replace + }) { + TelemetryService().logPageView(pageName, extra: { + 'nav_action': navAction, + if (previousPage != null && previousPage != 'unknown') + 'previous_page': previousPage, + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // RouteObserver 回调 + // ───────────────────────────────────────────────────────────────────────── + + /// 新页面入栈(push / pushNamed) + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + if (route is PageRoute) { + _track( + _resolvePageName(route), + previousPage: _resolvePageName(previousRoute), + navAction: 'push', + ); + } + } + + /// 页面被替换(pushReplacement / pushAndRemoveUntil) + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + if (newRoute is PageRoute) { + _track( + _resolvePageName(newRoute), + previousPage: _resolvePageName(oldRoute), + navAction: 'replace', + ); + } + } + + /// 页面出栈(pop / Navigator.pop),记录重新曝光的前一页 + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + if (previousRoute is PageRoute) { + _track( + _resolvePageName(previousRoute), + previousPage: _resolvePageName(route), + navAction: 'pop', + ); + } + } +} diff --git a/frontend/genex-mobile/lib/core/updater/channels/self_hosted_updater.dart b/frontend/genex-mobile/lib/core/updater/channels/self_hosted_updater.dart index 474a98d..f7f663e 100644 --- a/frontend/genex-mobile/lib/core/updater/channels/self_hosted_updater.dart +++ b/frontend/genex-mobile/lib/core/updater/channels/self_hosted_updater.dart @@ -7,6 +7,7 @@ import '../download_manager.dart'; import '../apk_installer.dart'; import '../app_market_detector.dart'; import '../models/version_info.dart'; +import '../../../telemetry/telemetry_service.dart'; /// 自建服务器更新器 class SelfHostedUpdater { @@ -35,6 +36,12 @@ class SelfHostedUpdater { } void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) { + TelemetryService().logUserAction('update_prompted', properties: { + 'channel': 'market', + 'new_version': versionInfo.version, + 'new_version_code': versionInfo.versionCode, + 'force_update': versionInfo.forceUpdate, + }); showDialog( context: context, barrierDismissible: !versionInfo.forceUpdate, @@ -79,7 +86,13 @@ class SelfHostedUpdater { actions: [ if (!versionInfo.forceUpdate) TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () { + TelemetryService().logUserAction('update_dismissed', properties: { + 'channel': 'market', + 'new_version': versionInfo.version, + }); + Navigator.pop(context); + }, child: Text( context.t('update.later'), style: TextStyle(fontSize: 14, color: Colors.grey[600]), @@ -87,6 +100,10 @@ class SelfHostedUpdater { ), ElevatedButton( onPressed: () async { + TelemetryService().logUserAction('update_accepted', properties: { + 'channel': 'market', + 'new_version': versionInfo.version, + }); Navigator.pop(context); final packageInfo = await versionChecker.getCurrentVersion(); await AppMarketDetector.openAppMarketDetail(packageInfo.packageName); @@ -104,6 +121,13 @@ class SelfHostedUpdater { } void _showSelfHostedUpdateDialog(BuildContext context, VersionInfo versionInfo) { + TelemetryService().logUserAction('update_prompted', properties: { + 'channel': 'self_hosted', + 'new_version': versionInfo.version, + 'new_version_code': versionInfo.versionCode, + 'force_update': versionInfo.forceUpdate, + 'file_size': versionInfo.fileSize, + }); showDialog( context: context, barrierDismissible: !versionInfo.forceUpdate, @@ -148,7 +172,13 @@ class SelfHostedUpdater { actions: [ if (!versionInfo.forceUpdate) TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () { + TelemetryService().logUserAction('update_dismissed', properties: { + 'channel': 'self_hosted', + 'new_version': versionInfo.version, + }); + Navigator.pop(context); + }, child: Text( context.t('update.skipUpdate'), style: TextStyle(fontSize: 14, color: Colors.grey[600]), @@ -156,6 +186,10 @@ class SelfHostedUpdater { ), ElevatedButton( onPressed: () { + TelemetryService().logUserAction('update_accepted', properties: { + 'channel': 'self_hosted', + 'new_version': versionInfo.version, + }); Navigator.pop(context); _startUpdate(context, versionInfo); }, @@ -237,6 +271,11 @@ class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { }); } + TelemetryService().logUserAction('update_download_started', properties: { + 'new_version': widget.versionInfo.version, + 'file_size': widget.versionInfo.fileSize, + }); + final apkFile = await widget.downloadManager.downloadApk( url: widget.versionInfo.downloadUrl, sha256Expected: widget.versionInfo.sha256, @@ -255,8 +294,13 @@ class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { if (!mounted) return; if (apkFile == null) { + final isCancelled = widget.downloadManager.status == DownloadStatus.cancelled; + TelemetryService().logUserAction( + isCancelled ? 'update_download_cancelled' : 'update_download_failed', + properties: {'new_version': widget.versionInfo.version}, + ); setState(() { - _statusText = widget.downloadManager.status == DownloadStatus.cancelled + _statusText = isCancelled ? context.t('update.cancelled') : context.t('update.failed'); _isDownloading = false; @@ -266,6 +310,10 @@ class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { } _downloadedApkFile = apkFile; + TelemetryService().logUserAction('update_download_completed', properties: { + 'new_version': widget.versionInfo.version, + 'force_update': widget.forceUpdate, + }); if (widget.forceUpdate) { setState(() => _statusText = context.t('update.installing')); @@ -293,8 +341,14 @@ class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { if (!mounted) return; if (installed) { + TelemetryService().logUserAction('update_install_triggered', properties: { + 'new_version': widget.versionInfo.version, + }); Navigator.pop(context); } else { + TelemetryService().logUserAction('update_install_failed', properties: { + 'new_version': widget.versionInfo.version, + }); setState(() { _statusText = context.t('update.installFailed'); _isDownloading = false; diff --git a/frontend/genex-mobile/lib/main.dart b/frontend/genex-mobile/lib/main.dart index 5b9f731..704fe99 100644 --- a/frontend/genex-mobile/lib/main.dart +++ b/frontend/genex-mobile/lib/main.dart @@ -9,6 +9,7 @@ import 'app/i18n/locale_manager.dart'; import 'core/services/auth_service.dart'; import 'core/updater/update_service.dart'; import 'core/telemetry/telemetry_service.dart'; +import 'core/telemetry/telemetry_route_observer.dart'; import 'core/updater/models/update_config.dart'; import 'core/push/push_service.dart'; import 'core/providers/notification_badge_manager.dart'; @@ -46,6 +47,21 @@ import 'features/profile/presentation/pages/share_page.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // 全局 Flutter 框架错误上报(如 Widget build 异常) + // 仅在 release 模式下上报,debug 模式保留默认的 red-screen + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.dumpErrorToConsole(details); + TelemetryService().logError( + details.exceptionAsString(), + error: details.exception, + stackTrace: details.stack, + extra: { + 'source': 'flutter_framework', + 'library': details.library ?? 'unknown', + }, + ); + }; + // ── 微信 SDK 初始化 (fluwx 5.x) ────────────────────────────────────── const wechatAppId = String.fromEnvironment('WECHAT_APP_ID', defaultValue: ''); if (wechatAppId.isNotEmpty) { @@ -157,6 +173,9 @@ class _GenexConsumerAppState extends ConsumerState { debugShowCheckedModeBanner: false, navigatorKey: _navigatorKey, + // 路由观察器:自动上报所有页面的 page_view 事件,支持 DAU/页面漏斗分析 + navigatorObservers: [TelemetryRouteObserver.instance], + // i18n locale: LocaleManager.userLocale.value, supportedLocales: LocaleManager.supportedLocales,