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:
parent
cc966fb022
commit
9b760c56ee
|
|
@ -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<void> logout() async {
|
||||
// 登出前先上报事件(clearAuth 会清空 userId,需在之前记录)
|
||||
TelemetryService().logUserAction('user_logout');
|
||||
try {
|
||||
await _api.post('/api/v1/auth/logout');
|
||||
} catch (_) {
|
||||
|
|
|
|||
|
|
@ -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<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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue