- 30 秒定时冲刷:AnalyticsService.startPeriodicFlush/stopPeriodicFlush, 前台期间 Timer.periodic 周期冲刷;SessionTracker 新增 onEnterForeground 回调,退后台停(并保留既有 flushNow 触发)、回前台恢复,App dispose 收尾。 - 失败退避:网络错误/5xx 后 30s→60s→120s 指数退避封顶 5 分钟,退避窗口 只挡定时冲刷(flushNow/满 20/冷启动显式触发不受限),上传成功即重置; 429 改按网络错误同路径保段重试(Retry-After 分支待后端限流落地)。 - anonymousId 持久化:restore 时从 shared_preferences 采用/落盘 pb.analytics.anonymousId,首次生成后跨冷启动稳定;读取失败降级 进程内临时 id 不崩溃。 - 测试 272 → 286(+14):fakeAsync 定时/退避 8 个、anonymousId 4 个、 429 保段 1 个、前后台回调成对 1 个;analyze 0 问题、format 无 diff。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Analytics client for report 13: track events to backend POST
|
||||
/// /api/v1/events. Events land in a segmented persistent queue
|
||||
/// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped),
|
||||
/// flushed every 20 events and on leaving foreground; cold start [restore]
|
||||
/// re-uploads offline backlog. Privacy red-line enforced locally.
|
||||
/// flushed every 20 events, every [flushInterval] while foregrounded
|
||||
/// ([startPeriodicFlush]) and on leaving foreground; cold start [restore]
|
||||
/// re-uploads offline backlog. Upload failures back off exponentially
|
||||
/// (periodic flush only; explicit triggers unaffected). Privacy red-line
|
||||
/// enforced locally.
|
||||
class AnalyticsService {
|
||||
AnalyticsService({
|
||||
required this.apiBaseUrl,
|
||||
@@ -16,10 +21,14 @@ class AnalyticsService {
|
||||
required this.getSessionId,
|
||||
String? anonymousId,
|
||||
AnalyticsEventStore? store,
|
||||
this.flushInterval = const Duration(seconds: 30),
|
||||
DateTime Function()? now,
|
||||
}) : _anonymousId = anonymousId ?? const Uuid().v4(),
|
||||
_anonymousIdInjected = anonymousId != null,
|
||||
_appVersion = 'unknown',
|
||||
_osVersion = _defaultOsVersion(),
|
||||
_store = store ?? AnalyticsEventStore();
|
||||
_store = store ?? AnalyticsEventStore(),
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
/// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。
|
||||
void setAppVersion(String version) {
|
||||
@@ -32,23 +41,49 @@ class AnalyticsService {
|
||||
// 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传。
|
||||
static const _maxBatchEvents = 50;
|
||||
|
||||
// 失败退避:首次 30 秒,×2 递增封顶 5 分钟(13 号 §3.4 / iteration-3
|
||||
// 06 号 §2.3);只挡定时冲刷,显式触发(flushNow / 满 20 / 冷启动)不受限。
|
||||
static const _backoffInitial = Duration(seconds: 30);
|
||||
static const _backoffCap = Duration(minutes: 5);
|
||||
|
||||
/// anonymousId 持久化 key(13 号规范 §3.3):首次生成后跨冷启动稳定,
|
||||
/// 登录前事件才能跨启动归并(A/B 前置 #4 硬依赖)。
|
||||
static const anonymousIdKey = 'pb.analytics.anonymousId';
|
||||
|
||||
final String apiBaseUrl;
|
||||
final String? Function()? getAccessToken;
|
||||
|
||||
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
|
||||
final String Function() getSessionId;
|
||||
|
||||
final String _anonymousId;
|
||||
/// 前台定时冲刷周期(13 号 §3.4 第 4 触发点),构造参数化便于测试注入。
|
||||
final Duration flushInterval;
|
||||
|
||||
String _anonymousId;
|
||||
|
||||
/// 构造显式注入 anonymousId 的测试通道不参与持久化采用/落盘。
|
||||
final bool _anonymousIdInjected;
|
||||
String _appVersion;
|
||||
final String _osVersion;
|
||||
String? _userId;
|
||||
bool _flushing = false;
|
||||
final AnalyticsEventStore _store;
|
||||
|
||||
/// 时钟注入口(照 SessionTracker 先例),退避判定测试免真实等待。
|
||||
final DateTime Function() _now;
|
||||
|
||||
Timer? _flushTimer;
|
||||
Duration? _backoffDelay;
|
||||
DateTime? _retryNotBefore;
|
||||
|
||||
/// 待上报事件(测试断言用,生产代码不得直接操作)。
|
||||
@visibleForTesting
|
||||
List<Map<String, dynamic>> get pendingEvents => _store.events;
|
||||
|
||||
/// 当前匿名标识(测试断言用)。
|
||||
@visibleForTesting
|
||||
String get anonymousId => _anonymousId;
|
||||
|
||||
/// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。
|
||||
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
|
||||
static String _defaultOsVersion() {
|
||||
@@ -121,10 +156,12 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 冷启动恢复持久化队列(离线积压约两周容量),有积压即冲刷一次
|
||||
/// (13 号规范 §3.4 冷启动触发)。app 启动时调用,不阻塞渲染。
|
||||
/// 冷启动恢复:先采用持久化 anonymousId,再恢复持久化队列(离线积压
|
||||
/// 约两周容量),有积压即冲刷一次(13 号规范 §3.4 冷启动触发)。
|
||||
/// app 启动时调用,不阻塞渲染。
|
||||
Future<void> restore() async {
|
||||
try {
|
||||
await _restoreAnonymousId();
|
||||
await _store.restore();
|
||||
if (_store.length > 0) {
|
||||
await _flush();
|
||||
@@ -134,6 +171,43 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 采用/落盘持久化 anonymousId:已有存储值则采用(跨启动稳定),
|
||||
/// 无则把本次生成的落盘。持久化不可用时降级为进程内临时 id,
|
||||
/// 绝不抛出(埋点旁路原则)。
|
||||
Future<void> _restoreAnonymousId() async {
|
||||
if (_anonymousIdInjected) return;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getString(anonymousIdKey);
|
||||
if (stored != null && stored.isNotEmpty) {
|
||||
_anonymousId = stored;
|
||||
} else {
|
||||
await prefs.setString(anonymousIdKey, _anonymousId);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('Analytics: anonymousId falls back to ephemeral: $error');
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动前台定时冲刷(13 号 §3.4 第 4 触发点):长前台会话(刷 Feed
|
||||
/// 半小时不切页)不再积压不上传。app 启动与回前台时调用,幂等。
|
||||
void startPeriodicFlush() {
|
||||
_flushTimer ??= Timer.periodic(flushInterval, (_) => _onFlushTimerTick());
|
||||
}
|
||||
|
||||
/// 停止定时冲刷(退后台与 App dispose 时调用),退避状态保留。
|
||||
void stopPeriodicFlush() {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
}
|
||||
|
||||
void _onFlushTimerTick() {
|
||||
// 退避窗口内跳过定时冲刷;显式触发(flushNow / 满 20 / 冷启动)不受限。
|
||||
final notBefore = _retryNotBefore;
|
||||
if (notBefore != null && _now().isBefore(notBefore)) return;
|
||||
_flush();
|
||||
}
|
||||
|
||||
/// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满
|
||||
/// [_flushThreshold] 条导致事件永不上传)。
|
||||
Future<void> flushNow() => _flush();
|
||||
@@ -147,22 +221,40 @@ class AnalyticsService {
|
||||
// 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
|
||||
final batch = _store.takeBatch(_maxBatchEvents);
|
||||
if (batch.isEmpty) break;
|
||||
final rejected = await _upload(batch.events);
|
||||
final rejected = await uploadBatch(batch.events);
|
||||
// 拿到服务端应答即连通性恢复,重置退避。
|
||||
_resetBackoff();
|
||||
// at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
|
||||
// 4xx 批次计入本地丢弃诊断数。
|
||||
await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
|
||||
}
|
||||
} catch (error) {
|
||||
// 网络错误 / 5xx:段保留在持久化队列,等下次触发或冷启动重传。
|
||||
// 网络错误 / 5xx:段保留在持久化队列,指数退避后由定时冲刷重试,
|
||||
// 或等显式触发/冷启动重传。
|
||||
_scheduleBackoff();
|
||||
debugPrint('Analytics upload failed, events kept queued: $error');
|
||||
} finally {
|
||||
_flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleBackoff() {
|
||||
final next = _backoffDelay == null ? _backoffInitial : _backoffDelay! * 2;
|
||||
_backoffDelay = next > _backoffCap ? _backoffCap : next;
|
||||
_retryNotBefore = _now().add(_backoffDelay!);
|
||||
}
|
||||
|
||||
void _resetBackoff() {
|
||||
_backoffDelay = null;
|
||||
_retryNotBefore = null;
|
||||
}
|
||||
|
||||
/// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃);
|
||||
/// 网络错误 / 5xx 抛异常(调用方保留段)。
|
||||
Future<bool> _upload(List<Map<String, dynamic>> events) async {
|
||||
/// 网络错误 / 5xx / 429 抛异常(调用方保留段并退避)。
|
||||
/// protected:测试子类以假上传替换,免起真实 HttpServer。
|
||||
@protected
|
||||
@visibleForTesting
|
||||
Future<bool> uploadBatch(List<Map<String, dynamic>> events) async {
|
||||
final token = getAccessToken?.call();
|
||||
final request =
|
||||
await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events'))
|
||||
@@ -173,6 +265,11 @@ class AnalyticsService {
|
||||
request.add(utf8.encode(jsonEncode({'events': events})));
|
||||
|
||||
final response = await request.close();
|
||||
if (response.statusCode == 429) {
|
||||
// 429 按网络错误同路径处理(保段 + 指数退避):后端限流尚未实现
|
||||
// (iteration-2/09 出入项),Retry-After 分支待其落地后一并做。
|
||||
throw Exception('Upload rate limited (429), events kept queued');
|
||||
}
|
||||
if (response.statusCode >= 400 && response.statusCode < 500) {
|
||||
// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
|
||||
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
|
||||
|
||||
@@ -15,6 +15,7 @@ class SessionTracker with WidgetsBindingObserver {
|
||||
SessionTracker({
|
||||
this.timeout = const Duration(minutes: 30),
|
||||
this.onLeaveForeground,
|
||||
this.onEnterForeground,
|
||||
DateTime Function()? now,
|
||||
}) : _now = now ?? DateTime.now,
|
||||
_sessionId = const Uuid().v7();
|
||||
@@ -25,6 +26,10 @@ class SessionTracker with WidgetsBindingObserver {
|
||||
/// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。
|
||||
final VoidCallback? onLeaveForeground;
|
||||
|
||||
/// 回到前台时回调(app 装配层用于恢复 30 秒定时冲刷);
|
||||
/// 冷启动首个 resumed 不触发(此前未离开过前台)。
|
||||
final VoidCallback? onEnterForeground;
|
||||
|
||||
/// 时钟注入口,测试免真实等待。
|
||||
final DateTime Function() _now;
|
||||
|
||||
@@ -39,8 +44,11 @@ class SessionTracker with WidgetsBindingObserver {
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
final leftAt = _leftForegroundAt;
|
||||
if (leftAt != null && _now().difference(leftAt) > timeout) {
|
||||
_sessionId = const Uuid().v7();
|
||||
if (leftAt != null) {
|
||||
if (_now().difference(leftAt) > timeout) {
|
||||
_sessionId = const Uuid().v7();
|
||||
}
|
||||
onEnterForeground?.call();
|
||||
}
|
||||
_leftForegroundAt = null;
|
||||
} else if (_lastState == AppLifecycleState.resumed) {
|
||||
|
||||
+9
-1
@@ -58,7 +58,12 @@ class _AppState extends State<App> {
|
||||
SessionManager(store: const SecureTokenStore());
|
||||
|
||||
_sessionTracker = SessionTracker(
|
||||
onLeaveForeground: () => _analytics.flushNow(),
|
||||
onLeaveForeground: () {
|
||||
// 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。
|
||||
_analytics.stopPeriodicFlush();
|
||||
_analytics.flushNow();
|
||||
},
|
||||
onEnterForeground: () => _analytics.startPeriodicFlush(),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(_sessionTracker);
|
||||
|
||||
@@ -70,6 +75,8 @@ class _AppState extends State<App> {
|
||||
_initAppVersion();
|
||||
// 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。
|
||||
_analytics.restore();
|
||||
// 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。
|
||||
_analytics.startPeriodicFlush();
|
||||
|
||||
_pageViewTracker = PageViewTracker(_analytics.trackEvent);
|
||||
_routeObserver = AnalyticsRouteObserver(
|
||||
@@ -161,6 +168,7 @@ class _AppState extends State<App> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_analytics.stopPeriodicFlush();
|
||||
sessionManager.removeListener(_reportAuthStateChange);
|
||||
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
||||
appState.dispose();
|
||||
|
||||
Reference in New Issue
Block a user