Files
lixi 4d40c38f06
CI / flutter-gates (push) Successful in 2m15s
新增:埋点队列三项完善——30 秒定时冲刷、失败指数退避、anonymousId 持久化(T3-19)
- 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>
2026-09-08 16:12:19 +08:00

297 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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,
required this.getAccessToken,
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(),
_now = now ?? DateTime.now;
/// 异步设置 appVersionapp.dart 启动时从 package_info_plus 读取后注入)。
void setAppVersion(String version) {
_appVersion = version;
}
// 满 _flushThreshold 条触发一次冲刷(13 号规范 §3.4)。
static const _flushThreshold = 20;
// 契约单批上限(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 持久化 key13 号规范 §3.3):首次生成后跨冷启动稳定,
/// 登录前事件才能跨启动归并(A/B 前置 #4 硬依赖)。
static const anonymousIdKey = 'pb.analytics.anonymousId';
final String apiBaseUrl;
final String? Function()? getAccessToken;
/// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。
final String Function() getSessionId;
/// 前台定时冲刷周期(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;
/// 粗粒度 osVersion13 号规范 §4.0:主版本级,如 android-14)。
/// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。
static String _defaultOsVersion() {
if (kIsWeb) {
return 'web-unknown';
}
final major = RegExp(
r'\d+',
).firstMatch(Platform.operatingSystemVersion)?.group(0);
return '${Platform.operatingSystem}-${major ?? 'unknown'}';
}
/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,
/// 上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期。
static String _platformName() {
if (kIsWeb) {
return 'web';
}
if (Platform.isAndroid) {
return 'android';
}
if (Platform.isIOS) {
return 'ios';
}
return Platform.operatingSystem;
}
/// Sets userId after login/restore.
void identify(String userId) {
_userId = userId;
}
/// Clears userId on logout (anonymousId remains).
void reset() {
_userId = null;
}
/// Tracks event (never throws, never awaits network). Props are validated
/// for privacy red-line patterns locally before queuing.
Future<void> trackEvent(
String eventName, [
Map<String, dynamic>? props,
]) async {
try {
if (props != null && _containsForbiddenField(props)) {
debugPrint('Analytics: event $eventName rejected (forbidden field)');
return;
}
final event = {
'eventId': const Uuid().v7(),
'eventName': eventName,
'eventVersion': 1,
'anonymousId': _anonymousId,
if (_userId != null) 'userId': _userId,
'sessionId': getSessionId(),
'clientTs': DateTime.now().toUtc().toIso8601String(),
'appVersion': _appVersion,
'platform': _platformName(),
'osVersion': _osVersion,
if (props != null && props.isNotEmpty) 'props': props,
};
await _store.add(event);
if (_store.length >= _flushThreshold) {
await _flush();
}
} catch (error) {
debugPrint('Analytics track failed: $error');
}
}
/// 冷启动恢复:先采用持久化 anonymousId,再恢复持久化队列(离线积压
/// 约两周容量),有积压即冲刷一次(13 号规范 §3.4 冷启动触发)。
/// app 启动时调用,不阻塞渲染。
Future<void> restore() async {
try {
await _restoreAnonymousId();
await _store.restore();
if (_store.length > 0) {
await _flush();
}
} catch (error) {
debugPrint('Analytics restore failed: $error');
}
}
/// 采用/落盘持久化 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();
Future<void> _flush() async {
if (_flushing) return;
_flushing = true;
try {
while (true) {
// 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。
final batch = _store.takeBatch(_maxBatchEvents);
if (batch.isEmpty) break;
final rejected = await uploadBatch(batch.events);
// 拿到服务端应答即连通性恢复,重置退避。
_resetBackoff();
// at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段;
// 4xx 批次计入本地丢弃诊断数。
await _store.removeSegments(batch.segmentIds, countAsDropped: rejected);
}
} catch (error) {
// 网络错误 / 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 / 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'))
..headers.contentType = ContentType.json;
if (token != null) {
request.headers.add('Authorization', 'Bearer $token');
}
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 为永久性拒绝(校验失败/批量超限等),重试不可能成功;
// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。
debugPrint(
'Analytics batch permanently rejected '
'(${response.statusCode}), dropping ${events.length} events',
);
return true;
}
if (response.statusCode != 202) {
throw Exception('Upload failed with ${response.statusCode}');
}
return false;
}
bool _containsForbiddenField(Map<String, dynamic> props) {
// Dart RegExp 不支持 (?i) 内联标志(原写法构造即抛异常,事件被静默丢弃)。
final pattern = RegExp(
r'password|token|secret|phone|mobile|email|credential|idfa|gaid',
caseSensitive: false,
);
return props.keys.any((key) => pattern.hasMatch(key));
}
}