feat(mobile/telemetry): add comprehensive event tracking for DAU, real-time presence & funnels

## 架构设计(亿级用户规模)
- 客户端:本地队列(500条) + 批量上传(20条/30s) + 10% 采样率
- 心跳:前台每60s发送,后端3分钟滑动窗口 → 实时在线率
- DAU:app_session_start 事件 + installId 去重
- 后端接入:Kafka → ClickHouse 消费落盘

## 新增 TelemetryRouteObserver (NEW FILE)
- 继承 RouteObserver<PageRoute>,注册到 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 09:39:48 -08:00
parent cc966fb022
commit 9b760c56ee
4 changed files with 198 additions and 3 deletions

View File

@ -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 {
///
/// Tokenfire-and-forget使
Future<void> logout() async {
// clearAuth userId
TelemetryService().logUserAction('user_logout');
try {
await _api.post('/api/v1/auth/logout');
} catch (_) {

View File

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'telemetry_service.dart';
/// 访page_view
///
/// MaterialApp navigatorObservers
/// navigatorObservers: [TelemetryRouteObserver.instance]
///
/// pushNamedbuilder: ...
/// - route.settings.name '/coupon/detail'
/// - runtimeType.toString() 'MaterialPageRoute'
/// settings: RouteSettings(name: '/xxx')
///
/// 亿
/// - page_view TelemetryConfig.pageViewEnabled + samplingRate10%
/// - 500 UI 线
/// - 20 / 30 Kafka ClickHouse
class TelemetryRouteObserver extends RouteObserver<PageRoute<dynamic>> {
/// Navigator
static final TelemetryRouteObserver instance = TelemetryRouteObserver._();
TelemetryRouteObserver._();
//
//
//
///
///
/// settings.name runtimeType
String _resolvePageName(Route<dynamic>? 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<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
if (route is PageRoute) {
_track(
_resolvePageName(route),
previousPage: _resolvePageName(previousRoute),
navAction: 'push',
);
}
}
/// pushReplacement / pushAndRemoveUntil
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? 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<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
if (previousRoute is PageRoute) {
_track(
_resolvePageName(previousRoute),
previousPage: _resolvePageName(route),
navAction: 'pop',
);
}
}
}

View File

@ -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;

View File

@ -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<void> 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<GenexConsumerApp> {
debugShowCheckedModeBanner: false,
navigatorKey: _navigatorKey,
// page_view DAU/
navigatorObservers: [TelemetryRouteObserver.instance],
// i18n
locale: LocaleManager.userLocale.value,
supportedLocales: LocaleManager.supportedLocales,