feat(telemetry): 完整实现 presence-service 迁移 + Flutter 遥测接入

## 后端 (backend/services/telemetry-service)

### 修复骨架中的关键缺陷
- 创建本地 JwtAuthGuard / AdminGuard,替换不存在的 @genex/common 包
- 修复 req.user.sub → req.user.id(与 JwtStrategy.validate() 返回值对齐)
- 重写 Dockerfile,移除对不存在的 packages/common 目录的引用
- 更新 telemetry.module.ts,将 JwtAuthGuard / AdminGuard 注册为 providers
- admin-telemetry.controller.ts 改用本地 AdminGuard(检查 role === 'admin')

### 新增功能
- GET /api/v1/telemetry/config:公开配置下发接口
  Flutter TelemetryConfig.syncFromRemote() 每小时拉取,返回全局开关、采样率、
  心跳间隔等配置,所有字段支持环境变量覆盖(TELEMETRY_GLOBAL_ENABLED 等)
- 设备字段(deviceBrand/deviceModel/deviceOs/appVersion/locale)提升为顶层列
  BatchEventsDto / TelemetryEvent 实体 / recordEvents() 全链路补齐
  原因:JSONB 内字段无法走 B-tree 索引,千万级数据分组查询需独立列

### DB 迁移
- 050_add_device_fields_to_telemetry_events.sql:为已有 telemetry_events 表
  新增 5 个设备字段列和 device_brand / app_version 索引

### docker-compose 环境变量
- telemetry-service 新增 TELEMETRY_GLOBAL_ENABLED / SAMPLING_RATE /
  HEARTBEAT_INTERVAL / CONFIG_VERSION 环境变量,支持生产环境热调整

## 前端

### genex-mobile (Flutter 消费者端)
- 复制 lib/core/telemetry/ 模块(11个文件):TelemetryService、
  HeartbeatService、SessionManager、TelemetryUploader、TelemetryStorage 等
- 修正 API 路径:presence/heartbeat → telemetry/heartbeat,
  analytics/events → telemetry/events
- pubspec.yaml 新增依赖:uuid ^4.3.3、equatable ^2.0.5、device_info_plus ^10.1.0
- main.dart:initState 首帧回调初始化 TelemetryService(需 BuildContext 采集设备信息)
  已登录时自动注入 accessToken
- auth_service.dart:_setAuth() 登录成功后注入 userId + accessToken;
  _clearAuth() 退出时清除(同时覆盖 Token 过期自动清除场景)

### admin-app (Flutter 发行方控制台)
- 复制 lib/core/telemetry/ 模块(同上)
- pubspec.yaml 新增依赖:uuid、equatable、device_info_plus、shared_preferences
- IssuerLoginPage:initState 首帧初始化 + 登录成功后注入 userId/token
  使用 api.gogenex.cn(与 UpdateService 域名一致)
- settings_page.dart:退出登录时调用 clearUserId() + clearAccessToken()

## 架构说明
- 在线人数:Redis Sorted Set (genex:presence:online),心跳 60s/次,180s 窗口判定在线
- DAU:app_session_start 事件写入 telemetry_events,每天凌晨 1 点聚合到
  daily_active_stats 表,同时每小时滚动更新当日 DAU
- 设备字段采用 Amplitude 风格:前端本地队列存 properties 内,
  toServerJson() 上传时自动提升为顶层字段,后端写入独立索引列
- 心跳需要 JWT 认证(未登录用户自动跳过,不报错)
- 遥测完全异步,任何失败只打 debug 日志,不影响主流程

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-05 23:33:13 -08:00
parent a6aeb8799e
commit ce9cc5b72e
40 changed files with 3759 additions and 42 deletions

View File

@ -360,6 +360,11 @@ services:
- REDIS_PORT=6379
- KAFKA_BROKERS=kafka:9092
- JWT_ACCESS_SECRET=dev-access-secret-change-in-production
# 遥测配置开关(生产环境可按需关闭某类事件)
- TELEMETRY_GLOBAL_ENABLED=true
- TELEMETRY_SAMPLING_RATE=0.1
- TELEMETRY_HEARTBEAT_INTERVAL=60
- TELEMETRY_CONFIG_VERSION=1.0.0
depends_on:
postgres:
condition: service_healthy

View File

@ -0,0 +1,14 @@
-- 为 telemetry_events 添加设备字段顶层列
-- 设备字段提升为独立列(而非放进 JSONB properties的原因
-- 按设备品牌/版本分组查询可以命中 B-tree 索引,千万级数据下毫秒级响应
-- 若放进 JSONB: properties->>'device_brand' 需全表扫描
ALTER TABLE telemetry_events
ADD COLUMN IF NOT EXISTS device_brand VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS device_model VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS device_os VARCHAR(32) NULL,
ADD COLUMN IF NOT EXISTS app_version VARCHAR(32) NULL,
ADD COLUMN IF NOT EXISTS locale VARCHAR(16) NULL;
CREATE INDEX IF NOT EXISTS idx_telemetry_events_device_brand ON telemetry_events(device_brand);
CREATE INDEX IF NOT EXISTS idx_telemetry_events_app_version ON telemetry_events(app_version);

View File

@ -1,34 +1,35 @@
# =============================================================================
# Telemetry Service Dockerfile
# =============================================================================
FROM node:20-alpine AS builder
WORKDIR /app
# Build shared @genex/common package
COPY packages/common/package*.json ./packages/common/
RUN cd packages/common && npm install
COPY packages/common/ ./packages/common/
RUN cd packages/common && npm run build
COPY package*.json ./
COPY tsconfig.json ./
COPY nest-cli.json ./
# Install service dependencies
COPY services/telemetry-service/package*.json ./services/telemetry-service/
WORKDIR /app/services/telemetry-service
RUN npm install
RUN npm ci
# Copy common package into node_modules for runtime resolution
RUN mkdir -p node_modules/@genex/common && \
cp -r /app/packages/common/dist node_modules/@genex/common/ && \
cp /app/packages/common/package.json node_modules/@genex/common/
COPY src ./src
# Copy service source and build
COPY services/telemetry-service/ ./
RUN npm run build
# ─── Production stage ────────────────────────────────────────────────────────
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache dumb-init
COPY --from=builder /app/services/telemetry-service/dist ./dist
COPY --from=builder /app/services/telemetry-service/node_modules ./node_modules
COPY --from=builder /app/services/telemetry-service/package.json ./
RUN apk add --no-cache dumb-init curl
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3011
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3011/api/v1/health || exit 1
CMD curl -f http://localhost:3011/api/v1/health || exit 1
CMD ["dumb-init", "node", "dist/main"]

View File

@ -26,6 +26,11 @@ export class TelemetryService {
installId: string;
eventName: string;
clientTs: number;
deviceBrand?: string;
deviceModel?: string;
deviceOs?: string;
appVersion?: string;
locale?: string;
properties?: Record<string, any>;
}>): Promise<{ recorded: number }> {
const timer = this.metrics.startBatchTimer();
@ -36,6 +41,11 @@ export class TelemetryService {
event.installId = e.installId;
event.eventName = e.eventName;
event.eventTime = new Date(e.clientTs * 1000);
event.deviceBrand = e.deviceBrand || null;
event.deviceModel = e.deviceModel || null;
event.deviceOs = e.deviceOs || null;
event.appVersion = e.appVersion || null;
event.locale = e.locale || null;
event.properties = e.properties || {};
return event;
});

View File

@ -0,0 +1,38 @@
-- =============================================================================
-- Telemetry Service - Initial Schema
-- =============================================================================
-- 遥测事件流水表append-only
CREATE TABLE IF NOT EXISTS telemetry_events (
id BIGSERIAL PRIMARY KEY,
user_id UUID NULL,
install_id VARCHAR(128) NOT NULL,
event_name VARCHAR(64) NOT NULL,
event_time TIMESTAMPTZ NOT NULL,
properties JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_telemetry_events_name_time ON telemetry_events (event_name, event_time);
CREATE INDEX IF NOT EXISTS idx_telemetry_events_user_id ON telemetry_events (user_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_events_time ON telemetry_events (event_time DESC);
-- 在线人数快照表(每分钟写入一次)
CREATE TABLE IF NOT EXISTS online_snapshots (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL UNIQUE,
online_count INT NOT NULL DEFAULT 0,
window_seconds INT NOT NULL DEFAULT 180
);
CREATE INDEX IF NOT EXISTS idx_online_snapshots_ts ON online_snapshots (ts DESC);
-- 日活统计汇总表
CREATE TABLE IF NOT EXISTS daily_active_stats (
day DATE PRIMARY KEY,
dau_count INT NOT NULL DEFAULT 0,
dau_by_platform JSONB NOT NULL DEFAULT '{}',
dau_by_region JSONB NOT NULL DEFAULT '{}',
calculated_at TIMESTAMPTZ NOT NULL,
version INT NOT NULL DEFAULT 1
);

View File

@ -18,6 +18,22 @@ export class TelemetryEvent {
@Column({ name: 'event_time', type: 'timestamptz' })
eventTime: Date;
// 设备字段顶层独立列B-tree 索引,千万级数据下按设备/版本分组查询毫秒级)
@Column({ name: 'device_brand', length: 64, nullable: true })
deviceBrand: string | null;
@Column({ name: 'device_model', length: 64, nullable: true })
deviceModel: string | null;
@Column({ name: 'device_os', length: 32, nullable: true })
deviceOs: string | null;
@Column({ name: 'app_version', length: 32, nullable: true })
appVersion: string | null;
@Column({ length: 16, nullable: true })
locale: string | null;
@Column({ type: 'jsonb', default: '{}' })
properties: Record<string, any>;

View File

@ -1,28 +1,26 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common';
import { JwtAuthGuard } from '../../../shared/guards/jwt-auth.guard';
import { AdminGuard } from '../../../shared/guards/admin.guard';
import { TelemetryService } from '../../../application/services/telemetry.service';
import { QueryDauDto, QueryEventsDto } from '../dto/query-dau.dto';
@ApiTags('admin-telemetry')
@Controller('admin/telemetry')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
export class AdminTelemetryController {
constructor(
private readonly telemetryService: TelemetryService,
) {}
constructor(private readonly telemetryService: TelemetryService) {}
@Get('dau')
@ApiOperation({ summary: 'Query DAU statistics' })
@ApiOperation({ summary: '查询 DAU 统计' })
async getDauStats(@Query() query: QueryDauDto) {
const result = await this.telemetryService.getDauStats(query.startDate, query.endDate);
return { code: 0, data: result };
}
@Get('events')
@ApiOperation({ summary: 'Query telemetry events' })
@ApiOperation({ summary: '查询遥测事件列表' })
async listEvents(@Query() query: QueryEventsDto) {
const page = query.page || 1;
const limit = query.limit || 20;
@ -36,7 +34,7 @@ export class AdminTelemetryController {
}
@Get('realtime')
@ApiOperation({ summary: 'Get realtime analytics dashboard data' })
@ApiOperation({ summary: '获取实时数据看板' })
async getRealtimeData() {
const data = await this.telemetryService.getRealtimeData();
return { code: 0, data };

View File

@ -1,6 +1,6 @@
import { Controller, Post, Get, Body, Query, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@genex/common';
import { JwtAuthGuard } from '../../../shared/guards/jwt-auth.guard';
import { TelemetryService } from '../../../application/services/telemetry.service';
import { BatchEventsDto } from '../dto/batch-events.dto';
import { HeartbeatDto } from '../dto/heartbeat.dto';
@ -10,8 +10,64 @@ import { HeartbeatDto } from '../dto/heartbeat.dto';
export class TelemetryController {
constructor(private readonly telemetryService: TelemetryService) {}
/**
*
*
* Flutter TelemetryConfig.syncFromRemote()
*
*
* 10%
*
*
* global_enabled false
* error_report_enabled / true
* performance_enabled
* user_action_enabled
* page_view_enabled 访
* session_enabled DAU
* sampling_rate 0~1 userAction/pageView/performance
* disabled_events eventName
* version
* presence_config
* heartbeat_interval_seconds 60
* requires_auth true:
* presence_enabled
*/
@Get('config')
@ApiOperation({
summary: '获取遥测客户端配置(公开接口,无需认证)',
description: 'Flutter TelemetryConfig.syncFromRemote() 调用,每小时同步一次',
})
getConfig() {
return {
code: 0,
data: {
global_enabled: process.env.TELEMETRY_GLOBAL_ENABLED !== 'false',
error_report_enabled: process.env.TELEMETRY_ERROR_ENABLED !== 'false',
performance_enabled: process.env.TELEMETRY_PERFORMANCE_ENABLED !== 'false',
user_action_enabled: process.env.TELEMETRY_USER_ACTION_ENABLED !== 'false',
page_view_enabled: process.env.TELEMETRY_PAGE_VIEW_ENABLED !== 'false',
session_enabled: process.env.TELEMETRY_SESSION_ENABLED !== 'false',
sampling_rate: parseFloat(process.env.TELEMETRY_SAMPLING_RATE || '0.1'),
disabled_events: (process.env.TELEMETRY_DISABLED_EVENTS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
version: process.env.TELEMETRY_CONFIG_VERSION || '1.0.0',
presence_config: {
heartbeat_interval_seconds: parseInt(
process.env.TELEMETRY_HEARTBEAT_INTERVAL || '60',
10,
),
requires_auth: process.env.TELEMETRY_HEARTBEAT_REQUIRES_AUTH !== 'false',
presence_enabled: process.env.TELEMETRY_PRESENCE_ENABLED !== 'false',
},
},
};
}
@Post('events')
@ApiOperation({ summary: 'Batch report telemetry events (no auth required)' })
@ApiOperation({ summary: '批量上报遥测事件(无需认证)' })
async batchEvents(@Body() body: BatchEventsDto) {
const result = await this.telemetryService.recordEvents(body.events);
return { code: 0, data: result };
@ -20,16 +76,17 @@ export class TelemetryController {
@Post('heartbeat')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Report heartbeat for online detection' })
@ApiOperation({ summary: '上报心跳(维持在线状态)' })
async heartbeat(@Req() req: any, @Body() body: HeartbeatDto) {
await this.telemetryService.recordHeartbeat(req.user.sub, body.installId, body.appVersion);
return { code: 0, data: { success: true } };
// JwtStrategy.validate() 返回 { id, role, kycLevel },故取 req.user.id
await this.telemetryService.recordHeartbeat(req.user.id, body.installId, body.appVersion);
return { code: 0, data: { ok: true, serverTs: Math.floor(Date.now() / 1000) } };
}
@Get('online-count')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current online user count' })
@ApiOperation({ summary: '获取当前在线人数' })
async getOnlineCount() {
const result = await this.telemetryService.getOnlineCount();
return { code: 0, data: result };
@ -38,7 +95,7 @@ export class TelemetryController {
@Get('online-history')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get online user history trend' })
@ApiOperation({ summary: '获取在线人数历史趋势' })
async getOnlineHistory(
@Query('startTime') startTime: string,
@Query('endTime') endTime: string,

View File

@ -12,11 +12,47 @@ import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class TelemetryEventItem {
@ApiProperty({ example: 'page_view', maxLength: 64 }) @IsString() @MaxLength(64) eventName: string;
@ApiProperty({ example: 'inst_abc123', maxLength: 128 }) @IsString() @MaxLength(128) installId: string;
@ApiPropertyOptional() @IsOptional() @IsUUID() userId?: string;
@ApiProperty({ example: 1700000000000 }) @IsNumber() clientTs: number;
@ApiPropertyOptional({ type: 'object' }) @IsOptional() @IsObject() properties?: Record<string, any>;
@ApiProperty({ example: 'page_view', maxLength: 64 })
@IsString() @MaxLength(64)
eventName: string;
@ApiProperty({ example: 'inst_abc123', maxLength: 128 })
@IsString() @MaxLength(128)
installId: string;
@ApiPropertyOptional()
@IsOptional() @IsUUID()
userId?: string;
/** Unix 时间戳(秒) */
@ApiProperty({ example: 1709644800 })
@IsNumber()
clientTs: number;
// 设备字段:顶层独立列,方便服务端按索引查询
@ApiPropertyOptional({ example: 'Xiaomi', maxLength: 64 })
@IsOptional() @IsString() @MaxLength(64)
deviceBrand?: string;
@ApiPropertyOptional({ example: 'Redmi Note 12', maxLength: 64 })
@IsOptional() @IsString() @MaxLength(64)
deviceModel?: string;
@ApiPropertyOptional({ example: '13', maxLength: 32 })
@IsOptional() @IsString() @MaxLength(32)
deviceOs?: string;
@ApiPropertyOptional({ example: '1.2.0', maxLength: 32 })
@IsOptional() @IsString() @MaxLength(32)
appVersion?: string;
@ApiPropertyOptional({ example: 'zh_CN', maxLength: 16 })
@IsOptional() @IsString() @MaxLength(16)
locale?: string;
@ApiPropertyOptional({ type: 'object' })
@IsOptional() @IsObject()
properties?: Record<string, any>;
}
export class BatchEventsDto {

View File

@ -0,0 +1,16 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
/**
* Verifies the JWT is valid (handled by JwtStrategy) and that user has admin role.
* Use after JwtAuthGuard: @UseGuards(JwtAuthGuard, AdminGuard)
*/
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) throw new UnauthorizedException('未认证');
if (user.role !== 'admin') throw new ForbiddenException('需要管理员权限');
return true;
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -32,6 +32,10 @@ import { TelemetryMetricsService } from './infrastructure/metrics/telemetry-metr
import { TelemetryService } from './application/services/telemetry.service';
import { TelemetrySchedulerService } from './application/services/telemetry-scheduler.service';
// Shared guards
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
import { AdminGuard } from './shared/guards/admin.guard';
// Interface - Controllers
import { TelemetryController } from './interface/http/controllers/telemetry.controller';
import { AdminTelemetryController } from './interface/http/controllers/admin-telemetry.controller';
@ -74,6 +78,8 @@ import { HealthController } from './interface/http/controllers/health.controller
],
providers: [
JwtStrategy,
JwtAuthGuard,
AdminGuard,
{ provide: TELEMETRY_EVENT_REPOSITORY, useClass: TelemetryEventRepository },
{ provide: ONLINE_SNAPSHOT_REPOSITORY, useClass: OnlineSnapshotRepository },
{ provide: DAILY_ACTIVE_STATS_REPOSITORY, useClass: DailyActiveStatsRepository },

View File

@ -0,0 +1,116 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../models/device_context.dart';
///
///
class DeviceInfoCollector {
static DeviceInfoCollector? _instance;
DeviceInfoCollector._();
factory DeviceInfoCollector() {
_instance ??= DeviceInfoCollector._();
return _instance!;
}
DeviceContext? _cachedContext;
/// ()
Future<DeviceContext> collect(BuildContext context) async {
if (_cachedContext != null) return _cachedContext!;
final deviceInfo = DeviceInfoPlugin();
final packageInfo = await PackageInfo.fromPlatform();
final mediaQuery = MediaQuery.of(context);
DeviceContext result;
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
result = DeviceContext(
platform: 'android',
brand: androidInfo.brand,
model: androidInfo.model,
manufacturer: androidInfo.manufacturer,
isPhysicalDevice: androidInfo.isPhysicalDevice,
osVersion: androidInfo.version.release,
sdkInt: androidInfo.version.sdkInt,
androidId: androidInfo.id, // ID,IMEI
screen: _collectScreenInfo(mediaQuery),
appName: packageInfo.appName,
packageName: packageInfo.packageName,
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
buildMode: _getBuildMode(),
locale: Platform.localeName,
timezone: DateTime.now().timeZoneName,
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
networkType: 'unknown', // connectivity包
collectedAt: DateTime.now(),
);
} else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
result = DeviceContext(
platform: 'ios',
brand: 'Apple',
model: iosInfo.model,
manufacturer: 'Apple',
isPhysicalDevice: iosInfo.isPhysicalDevice,
osVersion: iosInfo.systemVersion,
sdkInt: 0, // iOS没有SDK版本号
androidId: iosInfo.identifierForVendor ?? '',
screen: _collectScreenInfo(mediaQuery),
appName: packageInfo.appName,
packageName: packageInfo.packageName,
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
buildMode: _getBuildMode(),
locale: Platform.localeName,
timezone: DateTime.now().timeZoneName,
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
networkType: 'unknown',
collectedAt: DateTime.now(),
);
} else {
throw UnsupportedError('Unsupported platform');
}
_cachedContext = result;
return result;
}
///
ScreenInfo _collectScreenInfo(MediaQueryData mediaQuery) {
final size = mediaQuery.size;
final density = mediaQuery.devicePixelRatio;
return ScreenInfo(
widthPx: size.width * density,
heightPx: size.height * density,
density: density,
widthDp: size.width,
heightDp: size.height,
hasNotch: mediaQuery.padding.top > 24, //
);
}
///
String _getBuildMode() {
if (kReleaseMode) return 'release';
if (kProfileMode) return 'profile';
return 'debug';
}
/// ()
void clearCache() {
_cachedContext = null;
}
///
DeviceContext? get cachedContext => _cachedContext;
}

View File

@ -0,0 +1,174 @@
import 'package:equatable/equatable.dart';
///
class ScreenInfo extends Equatable {
final double widthPx;
final double heightPx;
final double density;
final double widthDp;
final double heightDp;
final bool hasNotch;
const ScreenInfo({
required this.widthPx,
required this.heightPx,
required this.density,
required this.widthDp,
required this.heightDp,
required this.hasNotch,
});
factory ScreenInfo.fromJson(Map<String, dynamic> json) {
return ScreenInfo(
widthPx: (json['widthPx'] as num).toDouble(),
heightPx: (json['heightPx'] as num).toDouble(),
density: (json['density'] as num).toDouble(),
widthDp: (json['widthDp'] as num).toDouble(),
heightDp: (json['heightDp'] as num).toDouble(),
hasNotch: json['hasNotch'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'widthPx': widthPx,
'heightPx': heightPx,
'density': density,
'widthDp': widthDp,
'heightDp': heightDp,
'hasNotch': hasNotch,
};
}
@override
List<Object?> get props =>
[widthPx, heightPx, density, widthDp, heightDp, hasNotch];
}
///
///
class DeviceContext extends Equatable {
//
final String platform; // 'android' | 'ios'
final String brand; // 'Samsung'
final String model; // 'SM-G9980'
final String manufacturer; // 'samsung'
final bool isPhysicalDevice; // true
//
final String osVersion; // '14'
final int sdkInt; // 34
final String androidId; // ID
//
final ScreenInfo screen;
// App信息
final String appName;
final String packageName;
final String appVersion;
final String buildNumber;
final String buildMode; // 'debug' | 'profile' | 'release'
//
final String locale; // 'zh_CN'
final String timezone; // 'Asia/Shanghai'
final bool isDarkMode;
final String networkType; // 'wifi' | 'cellular' | 'none'
//
final DateTime collectedAt;
const DeviceContext({
required this.platform,
required this.brand,
required this.model,
required this.manufacturer,
required this.isPhysicalDevice,
required this.osVersion,
required this.sdkInt,
required this.androidId,
required this.screen,
required this.appName,
required this.packageName,
required this.appVersion,
required this.buildNumber,
required this.buildMode,
required this.locale,
required this.timezone,
required this.isDarkMode,
required this.networkType,
required this.collectedAt,
});
factory DeviceContext.fromJson(Map<String, dynamic> json) {
return DeviceContext(
platform: json['platform'] as String,
brand: json['brand'] as String,
model: json['model'] as String,
manufacturer: json['manufacturer'] as String,
isPhysicalDevice: json['isPhysicalDevice'] as bool,
osVersion: json['osVersion'] as String,
sdkInt: json['sdkInt'] as int,
androidId: json['androidId'] as String,
screen: ScreenInfo.fromJson(json['screen'] as Map<String, dynamic>),
appName: json['appName'] as String,
packageName: json['packageName'] as String,
appVersion: json['appVersion'] as String,
buildNumber: json['buildNumber'] as String,
buildMode: json['buildMode'] as String,
locale: json['locale'] as String,
timezone: json['timezone'] as String,
isDarkMode: json['isDarkMode'] as bool,
networkType: json['networkType'] as String,
collectedAt: DateTime.parse(json['collectedAt'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'platform': platform,
'brand': brand,
'model': model,
'manufacturer': manufacturer,
'isPhysicalDevice': isPhysicalDevice,
'osVersion': osVersion,
'sdkInt': sdkInt,
'androidId': androidId,
'screen': screen.toJson(),
'appName': appName,
'packageName': packageName,
'appVersion': appVersion,
'buildNumber': buildNumber,
'buildMode': buildMode,
'locale': locale,
'timezone': timezone,
'isDarkMode': isDarkMode,
'networkType': networkType,
'collectedAt': collectedAt.toIso8601String(),
};
}
@override
List<Object?> get props => [
platform,
brand,
model,
manufacturer,
isPhysicalDevice,
osVersion,
sdkInt,
androidId,
screen,
appName,
packageName,
appVersion,
buildNumber,
buildMode,
locale,
timezone,
isDarkMode,
networkType,
collectedAt,
];
}

View File

@ -0,0 +1,152 @@
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/foundation.dart';
import 'telemetry_event.dart';
import '../presence/presence_config.dart';
///
///
class TelemetryConfig {
//
bool globalEnabled = true;
//
bool errorReportEnabled = true; //
bool performanceEnabled = true; //
bool userActionEnabled = true; //
bool pageViewEnabled = true; // 访
bool sessionEnabled = true; // DAU相关
//
double samplingRate = 0.1; // 10%
//
List<String> disabledEvents = [];
//
String configVersion = '1.0.0';
//
bool userOptIn = true;
// /线
PresenceConfig? presenceConfig;
static final TelemetryConfig _instance = TelemetryConfig._();
TelemetryConfig._();
factory TelemetryConfig() => _instance;
///
Future<void> syncFromRemote(String apiBaseUrl) async {
try {
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
// apiBaseUrl /api/v1
final response = await dio.get('$apiBaseUrl/telemetry/config');
final data = response.data;
globalEnabled = data['global_enabled'] ?? true;
errorReportEnabled = data['error_report_enabled'] ?? true;
performanceEnabled = data['performance_enabled'] ?? true;
userActionEnabled = data['user_action_enabled'] ?? true;
pageViewEnabled = data['page_view_enabled'] ?? true;
sessionEnabled = data['session_enabled'] ?? true;
samplingRate = (data['sampling_rate'] ?? 0.1).toDouble();
disabledEvents = List<String>.from(data['disabled_events'] ?? []);
configVersion = data['version'] ?? '1.0.0';
//
if (data['presence_config'] != null) {
presenceConfig = PresenceConfig.fromJson(data['presence_config']);
}
//
await _saveToLocal();
debugPrint('📊 Telemetry config synced (v$configVersion)');
debugPrint(
' Global: $globalEnabled, Sampling: ${(samplingRate * 100).toInt()}%');
debugPrint(' Presence: ${presenceConfig?.enabled ?? true}');
} catch (e) {
debugPrint('⚠️ Failed to sync telemetry config: $e');
//
await _loadFromLocal();
}
}
///
Future<void> _saveToLocal() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('telemetry_global_enabled', globalEnabled);
await prefs.setBool('telemetry_error_enabled', errorReportEnabled);
await prefs.setBool('telemetry_performance_enabled', performanceEnabled);
await prefs.setBool('telemetry_user_action_enabled', userActionEnabled);
await prefs.setBool('telemetry_page_view_enabled', pageViewEnabled);
await prefs.setBool('telemetry_session_enabled', sessionEnabled);
await prefs.setDouble('telemetry_sampling_rate', samplingRate);
await prefs.setStringList('telemetry_disabled_events', disabledEvents);
await prefs.setString('telemetry_config_version', configVersion);
}
///
Future<void> _loadFromLocal() async {
final prefs = await SharedPreferences.getInstance();
globalEnabled = prefs.getBool('telemetry_global_enabled') ?? true;
errorReportEnabled = prefs.getBool('telemetry_error_enabled') ?? true;
performanceEnabled =
prefs.getBool('telemetry_performance_enabled') ?? true;
userActionEnabled = prefs.getBool('telemetry_user_action_enabled') ?? true;
pageViewEnabled = prefs.getBool('telemetry_page_view_enabled') ?? true;
sessionEnabled = prefs.getBool('telemetry_session_enabled') ?? true;
samplingRate = prefs.getDouble('telemetry_sampling_rate') ?? 0.1;
disabledEvents = prefs.getStringList('telemetry_disabled_events') ?? [];
configVersion = prefs.getString('telemetry_config_version') ?? '1.0.0';
}
///
bool shouldLog(EventType type, String eventName) {
// 1.
if (!globalEnabled) return false;
// 2.
if (!userOptIn) return false;
// 3.
if (disabledEvents.contains(eventName)) return false;
// 4.
switch (type) {
case EventType.error:
case EventType.crash:
return errorReportEnabled;
case EventType.performance:
return performanceEnabled;
case EventType.userAction:
return userActionEnabled;
case EventType.pageView:
return pageViewEnabled;
case EventType.apiCall:
return performanceEnabled; // API调用归入性能监控
case EventType.session:
return sessionEnabled; //
case EventType.presence:
return presenceConfig?.enabled ?? true; // 线
}
}
///
Future<void> setUserOptIn(bool optIn) async {
userOptIn = optIn;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('telemetry_user_opt_in', optIn);
debugPrint('📊 User opt-in: $optIn');
}
///
Future<void> loadUserOptIn() async {
final prefs = await SharedPreferences.getInstance();
userOptIn = prefs.getBool('telemetry_user_opt_in') ?? true;
}
}

View File

@ -0,0 +1,194 @@
import 'package:equatable/equatable.dart';
///
enum EventLevel {
debug,
info,
warning,
error,
fatal,
}
///
enum EventType {
/// 访
pageView,
///
userAction,
/// API请求
apiCall,
///
performance,
///
error,
///
crash,
/// (app_session_start, app_session_end)
session,
/// 线 ()
presence,
}
///
class TelemetryEvent extends Equatable {
/// ID (UUID)
final String eventId;
///
final EventType type;
///
final EventLevel level;
/// : 'app_session_start', 'open_planting_page'
final String name;
///
final Map<String, dynamic>? properties;
///
final DateTime timestamp;
/// ID
final String? userId;
/// ID
final String? sessionId;
/// ID
final String installId;
/// ID
final String deviceContextId;
const TelemetryEvent({
required this.eventId,
required this.type,
required this.level,
required this.name,
this.properties,
required this.timestamp,
this.userId,
this.sessionId,
required this.installId,
required this.deviceContextId,
});
factory TelemetryEvent.fromJson(Map<String, dynamic> json) {
return TelemetryEvent(
eventId: json['eventId'] as String,
type: EventType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => EventType.userAction,
),
level: EventLevel.values.firstWhere(
(e) => e.name == json['level'],
orElse: () => EventLevel.info,
),
name: json['name'] as String,
properties: json['properties'] as Map<String, dynamic>?,
timestamp: DateTime.parse(json['timestamp'] as String),
userId: json['userId'] as String?,
sessionId: json['sessionId'] as String?,
installId: json['installId'] as String,
deviceContextId: json['deviceContextId'] as String,
);
}
/// JSON
Map<String, dynamic> toJson() {
return {
'eventId': eventId,
'type': type.name,
'level': level.name,
'name': name,
'properties': properties,
'timestamp': timestamp.toIso8601String(),
'userId': userId,
'sessionId': sessionId,
'installId': installId,
'deviceContextId': deviceContextId,
};
}
/// API Amplitude 便
/// : eventName, userId, installId, clientTs, deviceBrand, deviceModel,
/// deviceOs, appVersion, locale
/// properties:
Map<String, dynamic> toServerJson() {
// properties
final props = Map<String, dynamic>.from(properties ?? {});
final deviceBrand = props.remove('device_brand');
final deviceModel = props.remove('device_model');
final deviceOs = props.remove('device_os');
final appVersion = props.remove('app_version');
final locale = props.remove('locale');
return {
'eventName': name,
'userId': userId,
'installId': installId,
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
'deviceBrand': deviceBrand,
'deviceModel': deviceModel,
'deviceOs': deviceOs,
'appVersion': appVersion,
'locale': locale,
'properties': {
...props,
'eventId': eventId,
'type': type.name,
'level': level.name,
'sessionId': sessionId,
'deviceContextId': deviceContextId,
},
};
}
TelemetryEvent copyWith({
String? eventId,
EventType? type,
EventLevel? level,
String? name,
Map<String, dynamic>? properties,
DateTime? timestamp,
String? userId,
String? sessionId,
String? installId,
String? deviceContextId,
}) {
return TelemetryEvent(
eventId: eventId ?? this.eventId,
type: type ?? this.type,
level: level ?? this.level,
name: name ?? this.name,
properties: properties ?? this.properties,
timestamp: timestamp ?? this.timestamp,
userId: userId ?? this.userId,
sessionId: sessionId ?? this.sessionId,
installId: installId ?? this.installId,
deviceContextId: deviceContextId ?? this.deviceContextId,
);
}
@override
List<Object?> get props => [
eventId,
type,
level,
name,
properties,
timestamp,
userId,
sessionId,
installId,
deviceContextId,
];
}

View File

@ -0,0 +1,206 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import '../session/session_manager.dart';
import '../session/session_events.dart';
import 'presence_config.dart';
///
///
///
/// 1. App
/// 2.
/// 3.
class HeartbeatService {
static HeartbeatService? _instance;
HeartbeatService._();
factory HeartbeatService() {
_instance ??= HeartbeatService._();
return _instance!;
}
///
PresenceConfig _config = PresenceConfig.defaultConfig;
///
Timer? _heartbeatTimer;
///
bool _isRunning = false;
bool get isRunning => _isRunning;
///
DateTime? _lastHeartbeatAt;
DateTime? get lastHeartbeatAt => _lastHeartbeatAt;
///
int _heartbeatCount = 0;
int get heartbeatCount => _heartbeatCount;
/// API
String? _apiBaseUrl;
/// installId
String Function()? getInstallId;
/// userId
String? Function()? getUserId;
/// appVersion
String Function()? getAppVersion;
///
Map<String, String> Function()? getAuthHeaders;
late Dio _dio;
bool _isInitialized = false;
///
void initialize({
required String apiBaseUrl,
PresenceConfig? config,
required String Function() getInstallId,
required String? Function() getUserId,
required String Function() getAppVersion,
Map<String, String> Function()? getAuthHeaders,
}) {
if (_isInitialized) return;
_apiBaseUrl = apiBaseUrl;
_config = config ?? PresenceConfig.defaultConfig;
this.getInstallId = getInstallId;
this.getUserId = getUserId;
this.getAppVersion = getAppVersion;
this.getAuthHeaders = getAuthHeaders;
_dio = Dio(BaseOptions(
baseUrl: apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
//
final sessionManager = SessionManager();
sessionManager.onSessionStart = _onSessionStart;
sessionManager.onSessionEnd = _onSessionEnd;
//
if (sessionManager.state == SessionState.foreground) {
_startHeartbeat();
}
_isInitialized = true;
debugPrint(
'💓 [Heartbeat] Initialized, interval: ${_config.heartbeatIntervalSeconds}s');
}
///
void updateConfig(PresenceConfig config) {
final wasRunning = _isRunning;
if (wasRunning) {
_stopHeartbeat();
}
_config = config;
if (wasRunning && _config.enabled) {
_startHeartbeat();
}
debugPrint('💓 [Heartbeat] Config updated');
}
///
void dispose() {
_stopHeartbeat();
_isInitialized = false;
_instance = null;
debugPrint('💓 [Heartbeat] Disposed');
}
///
void _onSessionStart() {
if (_config.enabled) {
_startHeartbeat();
}
}
///
void _onSessionEnd() {
_stopHeartbeat();
}
///
void _startHeartbeat() {
if (_isRunning) return;
if (!_config.enabled) return;
_isRunning = true;
_heartbeatCount = 0;
//
_sendHeartbeat();
//
_heartbeatTimer = Timer.periodic(
Duration(seconds: _config.heartbeatIntervalSeconds),
(_) => _sendHeartbeat(),
);
debugPrint('💓 [Heartbeat] Started');
}
///
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_isRunning = false;
debugPrint('💓 [Heartbeat] Stopped (count: $_heartbeatCount)');
}
///
Future<void> _sendHeartbeat() async {
//
if (_config.requiresAuth && (getUserId?.call() == null)) {
debugPrint('💓 [Heartbeat] Skipped: user not logged in');
return;
}
try {
final response = await _dio.post(
'/api/v1/telemetry/heartbeat',
data: {
'installId': getInstallId?.call() ?? '',
'appVersion': getAppVersion?.call() ?? '',
'clientTs': DateTime.now().millisecondsSinceEpoch ~/ 1000,
},
options: Options(
headers: getAuthHeaders?.call(),
),
);
if (response.statusCode == 200) {
_lastHeartbeatAt = DateTime.now();
_heartbeatCount++;
debugPrint('💓 [Heartbeat] Sent #$_heartbeatCount');
}
} on DioException catch (e) {
//
debugPrint('💓 [Heartbeat] Failed (DioException): ${e.message}');
} catch (e) {
//
debugPrint('💓 [Heartbeat] Failed: $e');
}
}
///
@visibleForTesting
Future<void> forceHeartbeat() async {
await _sendHeartbeat();
}
}

View File

@ -0,0 +1,52 @@
///
class PresenceConfig {
///
/// 60 3
final int heartbeatIntervalSeconds;
///
/// true线
final bool requiresAuth;
///
final bool enabled;
const PresenceConfig({
this.heartbeatIntervalSeconds = 60,
this.requiresAuth = true,
this.enabled = true,
});
///
static const PresenceConfig defaultConfig = PresenceConfig();
///
factory PresenceConfig.fromJson(Map<String, dynamic> json) {
return PresenceConfig(
heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60,
requiresAuth: json['requires_auth'] ?? true,
enabled: json['presence_enabled'] ?? json['enabled'] ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'heartbeat_interval_seconds': heartbeatIntervalSeconds,
'requires_auth': requiresAuth,
'presence_enabled': enabled,
};
}
PresenceConfig copyWith({
int? heartbeatIntervalSeconds,
bool? requiresAuth,
bool? enabled,
}) {
return PresenceConfig(
heartbeatIntervalSeconds:
heartbeatIntervalSeconds ?? this.heartbeatIntervalSeconds,
requiresAuth: requiresAuth ?? this.requiresAuth,
enabled: enabled ?? this.enabled,
);
}
}

View File

@ -0,0 +1,29 @@
///
class SessionEvents {
/// App DAU
/// App
static const String sessionStart = 'app_session_start';
/// App
/// App
static const String sessionEnd = 'app_session_end';
/// 线
/// 60
static const String heartbeat = 'presence_heartbeat';
///
SessionEvents._();
}
///
enum SessionState {
///
foreground,
///
background,
///
unknown,
}

View File

@ -0,0 +1,162 @@
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
import '../telemetry_service.dart';
import '../models/telemetry_event.dart';
import 'session_events.dart';
///
///
///
/// 1. App app_session_start/app_session_end
/// 2. sessionId
/// 3. HeartbeatService
class SessionManager with WidgetsBindingObserver {
static SessionManager? _instance;
SessionManager._();
factory SessionManager() {
_instance ??= SessionManager._();
return _instance!;
}
/// ID
String? _currentSessionId;
String? get currentSessionId => _currentSessionId;
///
SessionState _state = SessionState.unknown;
SessionState get state => _state;
///
DateTime? _sessionStartTime;
/// HeartbeatService
VoidCallback? onSessionStart;
/// HeartbeatService
VoidCallback? onSessionEnd;
/// TelemetryService
TelemetryService? _telemetryService;
bool _isInitialized = false;
///
void initialize(TelemetryService telemetryService) {
if (_isInitialized) return;
_telemetryService = telemetryService;
WidgetsBinding.instance.addObserver(this);
//
_handleForeground();
_isInitialized = true;
debugPrint('📱 [Session] Manager initialized');
}
///
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_isInitialized = false;
_instance = null;
debugPrint('📱 [Session] Manager disposed');
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
_handleForeground();
break;
case AppLifecycleState.paused:
_handleBackground();
break;
case AppLifecycleState.inactive:
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
//
break;
}
}
///
void _handleForeground() {
if (_state == SessionState.foreground) return;
_state = SessionState.foreground;
_startNewSession();
// + session_start
// Amplitude/Mixpanel
_telemetryService?.flushOnBackground();
}
///
void _handleBackground() {
if (_state == SessionState.background) return;
_state = SessionState.background;
_endCurrentSession();
// session_end 10
_telemetryService?.flushOnBackground();
}
///
void _startNewSession() {
// sessionId
_currentSessionId = const Uuid().v4();
_sessionStartTime = DateTime.now();
// app_session_start DAU
_telemetryService?.logEvent(
SessionEvents.sessionStart,
type: EventType.session,
level: EventLevel.info,
properties: {
'session_id': _currentSessionId,
},
);
// HeartbeatService
onSessionStart?.call();
debugPrint('📱 [Session] Started: $_currentSessionId');
}
///
void _endCurrentSession() {
if (_currentSessionId == null) return;
final duration = _sessionStartTime != null
? DateTime.now().difference(_sessionStartTime!).inSeconds
: 0;
// app_session_end
_telemetryService?.logEvent(
SessionEvents.sessionEnd,
type: EventType.session,
level: EventLevel.info,
properties: {
'session_id': _currentSessionId,
'duration_seconds': duration,
},
);
// HeartbeatService
onSessionEnd?.call();
debugPrint('📱 [Session] Ended: $_currentSessionId (${duration}s)');
_currentSessionId = null;
_sessionStartTime = null;
}
///
int get sessionDurationSeconds {
if (_sessionStartTime == null) return 0;
return DateTime.now().difference(_sessionStartTime!).inSeconds;
}
}

View File

@ -0,0 +1,133 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/foundation.dart';
import '../models/telemetry_event.dart';
///
///
class TelemetryStorage {
static const String _keyEventQueue = 'telemetry_event_queue';
static const String _keyDeviceContext = 'telemetry_device_context';
static const String _keyInstallId = 'telemetry_install_id';
static const int _maxQueueSize = 500; // 500
late SharedPreferences _prefs;
bool _isInitialized = false;
///
Future<void> init() async {
if (_isInitialized) return;
_prefs = await SharedPreferences.getInstance();
_isInitialized = true;
}
///
Future<void> saveDeviceContext(Map<String, dynamic> context) async {
await _prefs.setString(_keyDeviceContext, jsonEncode(context));
}
///
Map<String, dynamic>? getDeviceContext() {
final str = _prefs.getString(_keyDeviceContext);
if (str == null) return null;
return jsonDecode(str) as Map<String, dynamic>;
}
/// InstallId
Future<void> saveInstallId(String installId) async {
await _prefs.setString(_keyInstallId, installId);
}
/// InstallId
String? getInstallId() {
return _prefs.getString(_keyInstallId);
}
///
Future<void> enqueueEvent(TelemetryEvent event) async {
final queue = _getEventQueue();
//
if (queue.length >= _maxQueueSize) {
queue.removeAt(0); //
debugPrint('⚠️ Telemetry queue full, removed oldest event');
}
queue.add(event.toJson());
await _saveEventQueue(queue);
}
///
Future<void> enqueueEvents(List<TelemetryEvent> events) async {
final queue = _getEventQueue();
for (var event in events) {
if (queue.length >= _maxQueueSize) break;
queue.add(event.toJson());
}
await _saveEventQueue(queue);
}
/// (N条)
List<TelemetryEvent> dequeueEvents(int limit) {
final queue = _getEventQueue();
final count = queue.length > limit ? limit : queue.length;
final events = queue
.take(count)
.map((json) => TelemetryEvent.fromJson(json))
.toList();
return events;
}
///
Future<void> removeEvents(int count) async {
final queue = _getEventQueue();
if (count >= queue.length) {
await clearEventQueue();
} else {
queue.removeRange(0, count);
await _saveEventQueue(queue);
}
}
///
int getQueueSize() {
return _getEventQueue().length;
}
///
Future<void> clearEventQueue() async {
await _prefs.remove(_keyEventQueue);
}
/// 退
/// installId deviceContext
Future<void> clearUserData() async {
if (!_isInitialized) {
await init();
}
await _prefs.remove(_keyEventQueue);
debugPrint('📊 TelemetryStorage: 已清除用户相关遥测数据');
}
//
List<Map<String, dynamic>> _getEventQueue() {
final str = _prefs.getString(_keyEventQueue);
if (str == null) return [];
try {
final List<dynamic> list = jsonDecode(str);
return list.cast<Map<String, dynamic>>();
} catch (e) {
debugPrint('⚠️ Failed to parse event queue: $e');
return [];
}
}
Future<void> _saveEventQueue(List<Map<String, dynamic>> queue) async {
await _prefs.setString(_keyEventQueue, jsonEncode(queue));
}
}

View File

@ -0,0 +1,396 @@
import 'dart:async';
import 'dart:math';
import 'package:uuid/uuid.dart';
import 'package:flutter/material.dart';
import 'models/telemetry_event.dart';
import 'models/device_context.dart';
import 'models/telemetry_config.dart';
import 'collectors/device_info_collector.dart';
import 'storage/telemetry_storage.dart';
import 'uploader/telemetry_uploader.dart';
import 'session/session_manager.dart';
import 'presence/heartbeat_service.dart';
import 'presence/presence_config.dart';
///
///
class TelemetryService {
static TelemetryService? _instance;
TelemetryService._();
factory TelemetryService() {
_instance ??= TelemetryService._();
return _instance!;
}
final _storage = TelemetryStorage();
late TelemetryUploader _uploader;
DeviceContext? _deviceContext;
/// IDDAU去重
late String _installId;
String get installId => _installId;
/// ID
String? _userId;
String? get userId => _userId;
/// 访/
String? _accessToken;
/// API
late String _apiBaseUrl;
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
///
late SessionManager _sessionManager;
///
late HeartbeatService _heartbeatService;
Timer? _configSyncTimer;
/// (main.dart中调用)
Future<void> initialize({
required String apiBaseUrl,
required BuildContext context,
String? userId,
Duration configSyncInterval = const Duration(hours: 1),
PresenceConfig? presenceConfig,
}) async {
if (_isInitialized) return;
_apiBaseUrl = apiBaseUrl;
// 1.
await _storage.init();
// 2. installId
await _initInstallId();
// 3.
await TelemetryConfig().loadUserOptIn();
// 4.
// 使
TelemetryConfig().syncFromRemote(apiBaseUrl).catchError((e) {
debugPrint('📊 [Telemetry] Remote config sync failed (non-blocking): $e');
});
// 5.
_deviceContext = await DeviceInfoCollector().collect(context);
await _storage.saveDeviceContext(_deviceContext!.toJson());
// 6. ID
_userId = userId;
// 7.
_uploader = TelemetryUploader(
apiBaseUrl: apiBaseUrl,
storage: _storage,
getAuthHeaders: _getAuthHeaders,
);
// 8.
if (TelemetryConfig().globalEnabled) {
_uploader.startPeriodicUpload();
}
// 9.
_configSyncTimer = Timer.periodic(configSyncInterval, (_) async {
await TelemetryConfig().syncFromRemote(apiBaseUrl);
//
if (TelemetryConfig().globalEnabled) {
_uploader.startPeriodicUpload();
} else {
_uploader.stopPeriodicUpload();
}
//
if (TelemetryConfig().presenceConfig != null) {
_heartbeatService.updateConfig(TelemetryConfig().presenceConfig!);
}
});
// 10.
_sessionManager = SessionManager();
_sessionManager.initialize(this);
// 11.
_heartbeatService = HeartbeatService();
_heartbeatService.initialize(
apiBaseUrl: apiBaseUrl,
config: presenceConfig ?? TelemetryConfig().presenceConfig,
getInstallId: () => _installId,
getUserId: () => _userId,
getAppVersion: () => _deviceContext?.appVersion ?? 'unknown',
getAuthHeaders: _getAuthHeaders,
);
_isInitialized = true;
debugPrint('📊 TelemetryService initialized');
debugPrint(' InstallId: $_installId');
debugPrint(' UserId: $_userId');
}
/// installId
Future<void> _initInstallId() async {
final storedId = _storage.getInstallId();
if (storedId != null) {
_installId = storedId;
} else {
_installId = const Uuid().v4();
await _storage.saveInstallId(_installId);
}
debugPrint('📊 [Telemetry] Install ID: $_installId');
}
/// 使 access token AccountService
Map<String, String> _getAuthHeaders() {
if (_accessToken != null) {
return {'Authorization': 'Bearer $_accessToken'};
}
return {};
}
/// ()
void logEvent(
String eventName, {
EventType type = EventType.userAction,
EventLevel level = EventLevel.info,
Map<String, dynamic>? properties,
}) {
if (!_isInitialized) {
debugPrint('⚠️ TelemetryService not initialized, event ignored');
return;
}
//
if (!TelemetryConfig().shouldLog(type, eventName)) {
return; //
}
//
if (_needsSampling(type)) {
if (Random().nextDouble() > TelemetryConfig().samplingRate) {
return; //
}
}
final deviceProps = _deviceContext != null
? {
'device_brand': _deviceContext!.brand,
'device_model': _deviceContext!.model,
'device_os': _deviceContext!.osVersion,
'app_version': _deviceContext!.appVersion,
'locale': _deviceContext!.locale,
}
: <String, dynamic>{};
final event = TelemetryEvent(
eventId: const Uuid().v4(),
type: type,
level: level,
name: eventName,
properties: {...deviceProps, ...?properties},
timestamp: DateTime.now(),
userId: _userId,
sessionId: _sessionManager.currentSessionId,
installId: _installId,
deviceContextId: _deviceContext?.androidId ?? '',
);
_storage.enqueueEvent(event);
//
_uploader.uploadIfNeeded();
}
///
bool _needsSampling(EventType type) {
// 100%
return type != EventType.error &&
type != EventType.crash &&
type != EventType.session;
}
/// 访
void logPageView(String pageName, {Map<String, dynamic>? extra}) {
logEvent(
'page_view',
type: EventType.pageView,
properties: {'page': pageName, ...?extra},
);
}
///
void logUserAction(String action, {Map<String, dynamic>? properties}) {
logEvent(
action,
type: EventType.userAction,
properties: properties,
);
}
///
void logError(
String errorMessage, {
Object? error,
StackTrace? stackTrace,
Map<String, dynamic>? extra,
}) {
logEvent(
'error_occurred',
type: EventType.error,
level: EventLevel.error,
properties: {
'message': errorMessage,
'error': error?.toString(),
'stack_trace': stackTrace?.toString(),
...?extra,
},
);
}
/// API调用
void logApiCall({
required String url,
required String method,
required int statusCode,
required int durationMs,
String? error,
}) {
logEvent(
'api_call',
type: EventType.apiCall,
level: error != null ? EventLevel.error : EventLevel.info,
properties: {
'url': url,
'method': method,
'status_code': statusCode,
'duration_ms': durationMs,
'error': error,
},
);
}
///
void logPerformance(
String metricName, {
required int durationMs,
Map<String, dynamic>? extra,
}) {
logEvent(
metricName,
type: EventType.performance,
properties: {'duration_ms': durationMs, ...?extra},
);
}
/// ID()
void setUserId(String? userId) {
_userId = userId;
debugPrint('📊 [Telemetry] User ID set: $userId');
}
/// ID(退)
void clearUserId() {
_userId = null;
debugPrint('📊 [Telemetry] User ID cleared');
}
/// 访/
void setAccessToken(String? token) {
_accessToken = token;
debugPrint('📊 [Telemetry] Access token ${token != null ? 'set' : 'cleared'}');
}
/// 访退
void clearAccessToken() {
_accessToken = null;
}
/// 退
///
Future<void> pauseForLogout() async {
_uploader.stopPeriodicUpload();
await _storage.clearEventQueue();
_userId = null;
debugPrint('📊 [Telemetry] Paused for logout');
}
///
void resumeAfterLogin() {
if (TelemetryConfig().globalEnabled) {
_uploader.startPeriodicUpload();
debugPrint('📊 [Telemetry] Resumed after login');
}
}
///
Future<void> setUserOptIn(bool optIn) async {
await TelemetryConfig().setUserOptIn(optIn);
if (!optIn) {
//
_uploader.stopPeriodicUpload();
await _storage.clearEventQueue();
debugPrint('📊 Telemetry disabled by user');
} else {
//
if (TelemetryConfig().globalEnabled) {
_uploader.startPeriodicUpload();
}
debugPrint('📊 Telemetry enabled by user');
}
}
// ========== 线 ==========
/// ID
String? get currentSessionId => _sessionManager.currentSessionId;
///
int get sessionDurationSeconds => _sessionManager.sessionDurationSeconds;
///
bool get isHeartbeatRunning => _heartbeatService.isRunning;
///
int get heartbeatCount => _heartbeatService.heartbeatCount;
///
void updatePresenceConfig(PresenceConfig config) {
_heartbeatService.updateConfig(config);
}
///
DeviceContext? get deviceContext => _deviceContext;
/// App进入后台时调用10
Future<void> flushOnBackground() async {
await _uploader.uploadBatch(batchSize: 50);
debugPrint('📊 [Telemetry] Flushed on background');
}
/// App退出前调用
Future<void> dispose() async {
_configSyncTimer?.cancel();
_sessionManager.dispose();
_heartbeatService.dispose();
await _uploader.forceUploadAll();
_isInitialized = false;
debugPrint('📊 TelemetryService disposed');
}
///
static void reset() {
_instance = null;
}
}

View File

@ -0,0 +1,115 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../storage/telemetry_storage.dart';
///
///
class TelemetryUploader {
final String apiBaseUrl;
final TelemetryStorage storage;
final Dio _dio;
Timer? _uploadTimer;
bool _isUploading = false;
///
Map<String, String> Function()? getAuthHeaders;
TelemetryUploader({
required this.apiBaseUrl,
required this.storage,
this.getAuthHeaders,
}) : _dio = Dio(BaseOptions(
baseUrl: apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
/// (3020)
void startPeriodicUpload({
Duration interval = const Duration(seconds: 30),
int batchSize = 20,
}) {
_uploadTimer?.cancel();
_uploadTimer = Timer.periodic(interval, (_) {
uploadIfNeeded(batchSize: batchSize);
});
debugPrint('📊 Telemetry uploader started (interval: ${interval.inSeconds}s)');
}
///
void stopPeriodicUpload() {
_uploadTimer?.cancel();
_uploadTimer = null;
debugPrint('📊 Telemetry uploader stopped');
}
/// ()
Future<void> uploadIfNeeded({int batchSize = 20}) async {
if (_isUploading) return;
final queueSize = storage.getQueueSize();
if (queueSize < 10) return; // 10,
await uploadBatch(batchSize: batchSize);
}
///
Future<bool> uploadBatch({int batchSize = 20}) async {
if (_isUploading) return false;
_isUploading = true;
try {
final events = storage.dequeueEvents(batchSize);
if (events.isEmpty) return true;
// API (使 toServerJson )
final response = await _dio.post(
'/api/v1/telemetry/events',
data: {
'events': events.map((e) => e.toServerJson()).toList(),
},
options: Options(
headers: getAuthHeaders?.call(),
),
);
if (response.statusCode == 200) {
// ,
await storage.removeEvents(events.length);
debugPrint('✅ Uploaded ${events.length} telemetry events');
return true;
} else {
debugPrint('❌ Upload failed: ${response.statusCode}');
return false;
}
} on DioException catch (e) {
debugPrint('❌ Upload error (DioException): ${e.message}');
return false;
} catch (e) {
debugPrint('❌ Upload error: $e');
return false;
} finally {
_isUploading = false;
}
}
/// (app退出前调用)
Future<void> forceUploadAll() async {
stopPeriodicUpload();
int retries = 0;
while (storage.getQueueSize() > 0 && retries < 3) {
final success = await uploadBatch(batchSize: 50);
if (!success) {
retries++;
await Future.delayed(const Duration(seconds: 1));
}
}
if (storage.getQueueSize() > 0) {
debugPrint('⚠️ ${storage.getQueueSize()} events remaining in queue');
}
}
}

View File

@ -4,6 +4,7 @@ import '../../../../app/router.dart';
import '../../../../app/i18n/app_localizations.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/telemetry/telemetry_service.dart';
///
///
@ -27,6 +28,20 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
final _authService = AuthService();
@override
void initState() {
super.initState();
//
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!TelemetryService().isInitialized) {
await TelemetryService().initialize(
apiBaseUrl: 'https://api.gogenex.cn',
context: context,
);
}
});
}
@override
void dispose() {
_phoneController.dispose();
@ -83,6 +98,11 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
try {
final result = await _authService.loginByPhone(phone, code);
ApiClient.instance.setToken(result.accessToken);
// userId token
if (TelemetryService().isInitialized) {
TelemetryService().setUserId(result.user?['id'] as String?);
TelemetryService().setAccessToken(result.accessToken);
}
if (!mounted) return;
Navigator.pushReplacementNamed(context, AppRouter.main);
} catch (e) {

View File

@ -7,6 +7,7 @@ import '../../../../core/updater/update_service.dart';
import '../../../../core/services/issuer_service.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/telemetry/telemetry_service.dart';
///
///
@ -83,6 +84,11 @@ class _SettingsPageState extends State<SettingsPage> {
debugPrint('[SettingsPage] logout error: $e');
}
// 退 userId token
if (TelemetryService().isInitialized) {
TelemetryService().clearUserId();
TelemetryService().clearAccessToken();
}
ApiClient.instance.setToken(null);
if (!mounted) return;

View File

@ -13,6 +13,10 @@ dependencies:
sdk: flutter
dio: ^5.4.3+1
package_info_plus: ^8.0.0
uuid: ^4.3.3
equatable: ^2.0.5
device_info_plus: ^10.1.0
shared_preferences: ^2.2.3
path_provider: ^2.1.0
crypto: ^3.0.3
permission_handler: ^11.3.1

View File

@ -23,6 +23,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
import '../storage/session_storage.dart';
import '../telemetry/telemetry_service.dart';
/// SMS
enum SmsCodeType {
@ -512,12 +513,16 @@ class AuthService {
Future<void> _setAuth(AuthResult result) async {
authState.value = result;
_api.setToken(result.accessToken);
// Token await
await SessionStorage.instance.save(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
);
// userId token
if (TelemetryService().isInitialized) {
TelemetryService().setUserId(result.user['id'] as String?);
TelemetryService().setAccessToken(result.accessToken);
}
}
/// /Token
@ -529,5 +534,10 @@ class AuthService {
authState.value = null;
_api.setToken(null);
await SessionStorage.instance.clear();
// 退 userId token
if (TelemetryService().isInitialized) {
TelemetryService().clearUserId();
TelemetryService().clearAccessToken();
}
}
}

View File

@ -0,0 +1,116 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../models/device_context.dart';
///
///
class DeviceInfoCollector {
static DeviceInfoCollector? _instance;
DeviceInfoCollector._();
factory DeviceInfoCollector() {
_instance ??= DeviceInfoCollector._();
return _instance!;
}
DeviceContext? _cachedContext;
/// ()
Future<DeviceContext> collect(BuildContext context) async {
if (_cachedContext != null) return _cachedContext!;
final deviceInfo = DeviceInfoPlugin();
final packageInfo = await PackageInfo.fromPlatform();
final mediaQuery = MediaQuery.of(context);
DeviceContext result;
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
result = DeviceContext(
platform: 'android',
brand: androidInfo.brand,
model: androidInfo.model,
manufacturer: androidInfo.manufacturer,
isPhysicalDevice: androidInfo.isPhysicalDevice,
osVersion: androidInfo.version.release,
sdkInt: androidInfo.version.sdkInt,
androidId: androidInfo.id, // ID,IMEI
screen: _collectScreenInfo(mediaQuery),
appName: packageInfo.appName,
packageName: packageInfo.packageName,
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
buildMode: _getBuildMode(),
locale: Platform.localeName,
timezone: DateTime.now().timeZoneName,
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
networkType: 'unknown', // connectivity包
collectedAt: DateTime.now(),
);
} else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
result = DeviceContext(
platform: 'ios',
brand: 'Apple',
model: iosInfo.model,
manufacturer: 'Apple',
isPhysicalDevice: iosInfo.isPhysicalDevice,
osVersion: iosInfo.systemVersion,
sdkInt: 0, // iOS没有SDK版本号
androidId: iosInfo.identifierForVendor ?? '',
screen: _collectScreenInfo(mediaQuery),
appName: packageInfo.appName,
packageName: packageInfo.packageName,
appVersion: packageInfo.version,
buildNumber: packageInfo.buildNumber,
buildMode: _getBuildMode(),
locale: Platform.localeName,
timezone: DateTime.now().timeZoneName,
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
networkType: 'unknown',
collectedAt: DateTime.now(),
);
} else {
throw UnsupportedError('Unsupported platform');
}
_cachedContext = result;
return result;
}
///
ScreenInfo _collectScreenInfo(MediaQueryData mediaQuery) {
final size = mediaQuery.size;
final density = mediaQuery.devicePixelRatio;
return ScreenInfo(
widthPx: size.width * density,
heightPx: size.height * density,
density: density,
widthDp: size.width,
heightDp: size.height,
hasNotch: mediaQuery.padding.top > 24, //
);
}
///
String _getBuildMode() {
if (kReleaseMode) return 'release';
if (kProfileMode) return 'profile';
return 'debug';
}
/// ()
void clearCache() {
_cachedContext = null;
}
///
DeviceContext? get cachedContext => _cachedContext;
}

View File

@ -0,0 +1,174 @@
import 'package:equatable/equatable.dart';
///
class ScreenInfo extends Equatable {
final double widthPx;
final double heightPx;
final double density;
final double widthDp;
final double heightDp;
final bool hasNotch;
const ScreenInfo({
required this.widthPx,
required this.heightPx,
required this.density,
required this.widthDp,
required this.heightDp,
required this.hasNotch,
});
factory ScreenInfo.fromJson(Map<String, dynamic> json) {
return ScreenInfo(
widthPx: (json['widthPx'] as num).toDouble(),
heightPx: (json['heightPx'] as num).toDouble(),
density: (json['density'] as num).toDouble(),
widthDp: (json['widthDp'] as num).toDouble(),
heightDp: (json['heightDp'] as num).toDouble(),
hasNotch: json['hasNotch'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'widthPx': widthPx,
'heightPx': heightPx,
'density': density,
'widthDp': widthDp,
'heightDp': heightDp,
'hasNotch': hasNotch,
};
}
@override
List<Object?> get props =>
[widthPx, heightPx, density, widthDp, heightDp, hasNotch];
}
///
///
class DeviceContext extends Equatable {
//
final String platform; // 'android' | 'ios'
final String brand; // 'Samsung'
final String model; // 'SM-G9980'
final String manufacturer; // 'samsung'
final bool isPhysicalDevice; // true
//
final String osVersion; // '14'
final int sdkInt; // 34
final String androidId; // ID
//
final ScreenInfo screen;
// App信息
final String appName;
final String packageName;
final String appVersion;
final String buildNumber;
final String buildMode; // 'debug' | 'profile' | 'release'
//
final String locale; // 'zh_CN'
final String timezone; // 'Asia/Shanghai'
final bool isDarkMode;
final String networkType; // 'wifi' | 'cellular' | 'none'
//
final DateTime collectedAt;
const DeviceContext({
required this.platform,
required this.brand,
required this.model,
required this.manufacturer,
required this.isPhysicalDevice,
required this.osVersion,
required this.sdkInt,
required this.androidId,
required this.screen,
required this.appName,
required this.packageName,
required this.appVersion,
required this.buildNumber,
required this.buildMode,
required this.locale,
required this.timezone,
required this.isDarkMode,
required this.networkType,
required this.collectedAt,
});
factory DeviceContext.fromJson(Map<String, dynamic> json) {
return DeviceContext(
platform: json['platform'] as String,
brand: json['brand'] as String,
model: json['model'] as String,
manufacturer: json['manufacturer'] as String,
isPhysicalDevice: json['isPhysicalDevice'] as bool,
osVersion: json['osVersion'] as String,
sdkInt: json['sdkInt'] as int,
androidId: json['androidId'] as String,
screen: ScreenInfo.fromJson(json['screen'] as Map<String, dynamic>),
appName: json['appName'] as String,
packageName: json['packageName'] as String,
appVersion: json['appVersion'] as String,
buildNumber: json['buildNumber'] as String,
buildMode: json['buildMode'] as String,
locale: json['locale'] as String,
timezone: json['timezone'] as String,
isDarkMode: json['isDarkMode'] as bool,
networkType: json['networkType'] as String,
collectedAt: DateTime.parse(json['collectedAt'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'platform': platform,
'brand': brand,
'model': model,
'manufacturer': manufacturer,
'isPhysicalDevice': isPhysicalDevice,
'osVersion': osVersion,
'sdkInt': sdkInt,
'androidId': androidId,
'screen': screen.toJson(),
'appName': appName,
'packageName': packageName,
'appVersion': appVersion,
'buildNumber': buildNumber,
'buildMode': buildMode,
'locale': locale,
'timezone': timezone,
'isDarkMode': isDarkMode,
'networkType': networkType,
'collectedAt': collectedAt.toIso8601String(),
};
}
@override
List<Object?> get props => [
platform,
brand,
model,
manufacturer,
isPhysicalDevice,
osVersion,
sdkInt,
androidId,
screen,
appName,
packageName,
appVersion,
buildNumber,
buildMode,
locale,
timezone,
isDarkMode,
networkType,
collectedAt,
];
}

View File

@ -0,0 +1,152 @@
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/foundation.dart';
import 'telemetry_event.dart';
import '../presence/presence_config.dart';
///
///
class TelemetryConfig {
//
bool globalEnabled = true;
//
bool errorReportEnabled = true; //
bool performanceEnabled = true; //
bool userActionEnabled = true; //
bool pageViewEnabled = true; // 访
bool sessionEnabled = true; // DAU相关
//
double samplingRate = 0.1; // 10%
//
List<String> disabledEvents = [];
//
String configVersion = '1.0.0';
//
bool userOptIn = true;
// /线
PresenceConfig? presenceConfig;
static final TelemetryConfig _instance = TelemetryConfig._();
TelemetryConfig._();
factory TelemetryConfig() => _instance;
///
Future<void> syncFromRemote(String apiBaseUrl) async {
try {
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
// apiBaseUrl /api/v1
final response = await dio.get('$apiBaseUrl/telemetry/config');
final data = response.data;
globalEnabled = data['global_enabled'] ?? true;
errorReportEnabled = data['error_report_enabled'] ?? true;
performanceEnabled = data['performance_enabled'] ?? true;
userActionEnabled = data['user_action_enabled'] ?? true;
pageViewEnabled = data['page_view_enabled'] ?? true;
sessionEnabled = data['session_enabled'] ?? true;
samplingRate = (data['sampling_rate'] ?? 0.1).toDouble();
disabledEvents = List<String>.from(data['disabled_events'] ?? []);
configVersion = data['version'] ?? '1.0.0';
//
if (data['presence_config'] != null) {
presenceConfig = PresenceConfig.fromJson(data['presence_config']);
}
//
await _saveToLocal();
debugPrint('📊 Telemetry config synced (v$configVersion)');
debugPrint(
' Global: $globalEnabled, Sampling: ${(samplingRate * 100).toInt()}%');
debugPrint(' Presence: ${presenceConfig?.enabled ?? true}');
} catch (e) {
debugPrint('⚠️ Failed to sync telemetry config: $e');
//
await _loadFromLocal();
}
}
///
Future<void> _saveToLocal() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('telemetry_global_enabled', globalEnabled);
await prefs.setBool('telemetry_error_enabled', errorReportEnabled);
await prefs.setBool('telemetry_performance_enabled', performanceEnabled);
await prefs.setBool('telemetry_user_action_enabled', userActionEnabled);
await prefs.setBool('telemetry_page_view_enabled', pageViewEnabled);
await prefs.setBool('telemetry_session_enabled', sessionEnabled);
await prefs.setDouble('telemetry_sampling_rate', samplingRate);
await prefs.setStringList('telemetry_disabled_events', disabledEvents);
await prefs.setString('telemetry_config_version', configVersion);
}
///
Future<void> _loadFromLocal() async {
final prefs = await SharedPreferences.getInstance();
globalEnabled = prefs.getBool('telemetry_global_enabled') ?? true;
errorReportEnabled = prefs.getBool('telemetry_error_enabled') ?? true;
performanceEnabled =
prefs.getBool('telemetry_performance_enabled') ?? true;
userActionEnabled = prefs.getBool('telemetry_user_action_enabled') ?? true;
pageViewEnabled = prefs.getBool('telemetry_page_view_enabled') ?? true;
sessionEnabled = prefs.getBool('telemetry_session_enabled') ?? true;
samplingRate = prefs.getDouble('telemetry_sampling_rate') ?? 0.1;
disabledEvents = prefs.getStringList('telemetry_disabled_events') ?? [];
configVersion = prefs.getString('telemetry_config_version') ?? '1.0.0';
}
///
bool shouldLog(EventType type, String eventName) {
// 1.
if (!globalEnabled) return false;
// 2.
if (!userOptIn) return false;
// 3.
if (disabledEvents.contains(eventName)) return false;
// 4.
switch (type) {
case EventType.error:
case EventType.crash:
return errorReportEnabled;
case EventType.performance:
return performanceEnabled;
case EventType.userAction:
return userActionEnabled;
case EventType.pageView:
return pageViewEnabled;
case EventType.apiCall:
return performanceEnabled; // API调用归入性能监控
case EventType.session:
return sessionEnabled; //
case EventType.presence:
return presenceConfig?.enabled ?? true; // 线
}
}
///
Future<void> setUserOptIn(bool optIn) async {
userOptIn = optIn;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('telemetry_user_opt_in', optIn);
debugPrint('📊 User opt-in: $optIn');
}
///
Future<void> loadUserOptIn() async {
final prefs = await SharedPreferences.getInstance();
userOptIn = prefs.getBool('telemetry_user_opt_in') ?? true;
}
}

View File

@ -0,0 +1,194 @@
import 'package:equatable/equatable.dart';
///
enum EventLevel {
debug,
info,
warning,
error,
fatal,
}
///
enum EventType {
/// 访
pageView,
///
userAction,
/// API请求
apiCall,
///
performance,
///
error,
///
crash,
/// (app_session_start, app_session_end)
session,
/// 线 ()
presence,
}
///
class TelemetryEvent extends Equatable {
/// ID (UUID)
final String eventId;
///
final EventType type;
///
final EventLevel level;
/// : 'app_session_start', 'open_planting_page'
final String name;
///
final Map<String, dynamic>? properties;
///
final DateTime timestamp;
/// ID
final String? userId;
/// ID
final String? sessionId;
/// ID
final String installId;
/// ID
final String deviceContextId;
const TelemetryEvent({
required this.eventId,
required this.type,
required this.level,
required this.name,
this.properties,
required this.timestamp,
this.userId,
this.sessionId,
required this.installId,
required this.deviceContextId,
});
factory TelemetryEvent.fromJson(Map<String, dynamic> json) {
return TelemetryEvent(
eventId: json['eventId'] as String,
type: EventType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => EventType.userAction,
),
level: EventLevel.values.firstWhere(
(e) => e.name == json['level'],
orElse: () => EventLevel.info,
),
name: json['name'] as String,
properties: json['properties'] as Map<String, dynamic>?,
timestamp: DateTime.parse(json['timestamp'] as String),
userId: json['userId'] as String?,
sessionId: json['sessionId'] as String?,
installId: json['installId'] as String,
deviceContextId: json['deviceContextId'] as String,
);
}
/// JSON
Map<String, dynamic> toJson() {
return {
'eventId': eventId,
'type': type.name,
'level': level.name,
'name': name,
'properties': properties,
'timestamp': timestamp.toIso8601String(),
'userId': userId,
'sessionId': sessionId,
'installId': installId,
'deviceContextId': deviceContextId,
};
}
/// API Amplitude 便
/// : eventName, userId, installId, clientTs, deviceBrand, deviceModel,
/// deviceOs, appVersion, locale
/// properties:
Map<String, dynamic> toServerJson() {
// properties
final props = Map<String, dynamic>.from(properties ?? {});
final deviceBrand = props.remove('device_brand');
final deviceModel = props.remove('device_model');
final deviceOs = props.remove('device_os');
final appVersion = props.remove('app_version');
final locale = props.remove('locale');
return {
'eventName': name,
'userId': userId,
'installId': installId,
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
'deviceBrand': deviceBrand,
'deviceModel': deviceModel,
'deviceOs': deviceOs,
'appVersion': appVersion,
'locale': locale,
'properties': {
...props,
'eventId': eventId,
'type': type.name,
'level': level.name,
'sessionId': sessionId,
'deviceContextId': deviceContextId,
},
};
}
TelemetryEvent copyWith({
String? eventId,
EventType? type,
EventLevel? level,
String? name,
Map<String, dynamic>? properties,
DateTime? timestamp,
String? userId,
String? sessionId,
String? installId,
String? deviceContextId,
}) {
return TelemetryEvent(
eventId: eventId ?? this.eventId,
type: type ?? this.type,
level: level ?? this.level,
name: name ?? this.name,
properties: properties ?? this.properties,
timestamp: timestamp ?? this.timestamp,
userId: userId ?? this.userId,
sessionId: sessionId ?? this.sessionId,
installId: installId ?? this.installId,
deviceContextId: deviceContextId ?? this.deviceContextId,
);
}
@override
List<Object?> get props => [
eventId,
type,
level,
name,
properties,
timestamp,
userId,
sessionId,
installId,
deviceContextId,
];
}

View File

@ -0,0 +1,206 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import '../session/session_manager.dart';
import '../session/session_events.dart';
import 'presence_config.dart';
///
///
///
/// 1. App
/// 2.
/// 3.
class HeartbeatService {
static HeartbeatService? _instance;
HeartbeatService._();
factory HeartbeatService() {
_instance ??= HeartbeatService._();
return _instance!;
}
///
PresenceConfig _config = PresenceConfig.defaultConfig;
///
Timer? _heartbeatTimer;
///
bool _isRunning = false;
bool get isRunning => _isRunning;
///
DateTime? _lastHeartbeatAt;
DateTime? get lastHeartbeatAt => _lastHeartbeatAt;
///
int _heartbeatCount = 0;
int get heartbeatCount => _heartbeatCount;
/// API
String? _apiBaseUrl;
/// installId
String Function()? getInstallId;
/// userId
String? Function()? getUserId;
/// appVersion
String Function()? getAppVersion;
///
Map<String, String> Function()? getAuthHeaders;
late Dio _dio;
bool _isInitialized = false;
///
void initialize({
required String apiBaseUrl,
PresenceConfig? config,
required String Function() getInstallId,
required String? Function() getUserId,
required String Function() getAppVersion,
Map<String, String> Function()? getAuthHeaders,
}) {
if (_isInitialized) return;
_apiBaseUrl = apiBaseUrl;
_config = config ?? PresenceConfig.defaultConfig;
this.getInstallId = getInstallId;
this.getUserId = getUserId;
this.getAppVersion = getAppVersion;
this.getAuthHeaders = getAuthHeaders;
_dio = Dio(BaseOptions(
baseUrl: apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
//
final sessionManager = SessionManager();
sessionManager.onSessionStart = _onSessionStart;
sessionManager.onSessionEnd = _onSessionEnd;
//
if (sessionManager.state == SessionState.foreground) {
_startHeartbeat();
}
_isInitialized = true;
debugPrint(
'💓 [Heartbeat] Initialized, interval: ${_config.heartbeatIntervalSeconds}s');
}
///
void updateConfig(PresenceConfig config) {
final wasRunning = _isRunning;
if (wasRunning) {
_stopHeartbeat();
}
_config = config;
if (wasRunning && _config.enabled) {
_startHeartbeat();
}
debugPrint('💓 [Heartbeat] Config updated');
}
///
void dispose() {
_stopHeartbeat();
_isInitialized = false;
_instance = null;
debugPrint('💓 [Heartbeat] Disposed');
}
///
void _onSessionStart() {
if (_config.enabled) {
_startHeartbeat();
}
}
///
void _onSessionEnd() {
_stopHeartbeat();
}
///
void _startHeartbeat() {
if (_isRunning) return;
if (!_config.enabled) return;
_isRunning = true;
_heartbeatCount = 0;
//
_sendHeartbeat();
//
_heartbeatTimer = Timer.periodic(
Duration(seconds: _config.heartbeatIntervalSeconds),
(_) => _sendHeartbeat(),
);
debugPrint('💓 [Heartbeat] Started');
}
///
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_isRunning = false;
debugPrint('💓 [Heartbeat] Stopped (count: $_heartbeatCount)');
}
///
Future<void> _sendHeartbeat() async {
//
if (_config.requiresAuth && (getUserId?.call() == null)) {
debugPrint('💓 [Heartbeat] Skipped: user not logged in');
return;
}
try {
final response = await _dio.post(
'/api/v1/telemetry/heartbeat',
data: {
'installId': getInstallId?.call() ?? '',
'appVersion': getAppVersion?.call() ?? '',
'clientTs': DateTime.now().millisecondsSinceEpoch ~/ 1000,
},
options: Options(
headers: getAuthHeaders?.call(),
),
);
if (response.statusCode == 200) {
_lastHeartbeatAt = DateTime.now();
_heartbeatCount++;
debugPrint('💓 [Heartbeat] Sent #$_heartbeatCount');
}
} on DioException catch (e) {
//
debugPrint('💓 [Heartbeat] Failed (DioException): ${e.message}');
} catch (e) {
//
debugPrint('💓 [Heartbeat] Failed: $e');
}
}
///
@visibleForTesting
Future<void> forceHeartbeat() async {
await _sendHeartbeat();
}
}

View File

@ -0,0 +1,52 @@
///
class PresenceConfig {
///
/// 60 3
final int heartbeatIntervalSeconds;
///
/// true线
final bool requiresAuth;
///
final bool enabled;
const PresenceConfig({
this.heartbeatIntervalSeconds = 60,
this.requiresAuth = true,
this.enabled = true,
});
///
static const PresenceConfig defaultConfig = PresenceConfig();
///
factory PresenceConfig.fromJson(Map<String, dynamic> json) {
return PresenceConfig(
heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60,
requiresAuth: json['requires_auth'] ?? true,
enabled: json['presence_enabled'] ?? json['enabled'] ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'heartbeat_interval_seconds': heartbeatIntervalSeconds,
'requires_auth': requiresAuth,
'presence_enabled': enabled,
};
}
PresenceConfig copyWith({
int? heartbeatIntervalSeconds,
bool? requiresAuth,
bool? enabled,
}) {
return PresenceConfig(
heartbeatIntervalSeconds:
heartbeatIntervalSeconds ?? this.heartbeatIntervalSeconds,
requiresAuth: requiresAuth ?? this.requiresAuth,
enabled: enabled ?? this.enabled,
);
}
}

View File

@ -0,0 +1,29 @@
///
class SessionEvents {
/// App DAU
/// App
static const String sessionStart = 'app_session_start';
/// App
/// App
static const String sessionEnd = 'app_session_end';
/// 线
/// 60
static const String heartbeat = 'presence_heartbeat';
///
SessionEvents._();
}
///
enum SessionState {
///
foreground,
///
background,
///
unknown,
}

View File

@ -0,0 +1,162 @@
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
import '../telemetry_service.dart';
import '../models/telemetry_event.dart';
import 'session_events.dart';
///
///
///
/// 1. App app_session_start/app_session_end
/// 2. sessionId
/// 3. HeartbeatService
class SessionManager with WidgetsBindingObserver {
static SessionManager? _instance;
SessionManager._();
factory SessionManager() {
_instance ??= SessionManager._();
return _instance!;
}
/// ID
String? _currentSessionId;
String? get currentSessionId => _currentSessionId;
///
SessionState _state = SessionState.unknown;
SessionState get state => _state;
///
DateTime? _sessionStartTime;
/// HeartbeatService
VoidCallback? onSessionStart;
/// HeartbeatService
VoidCallback? onSessionEnd;
/// TelemetryService
TelemetryService? _telemetryService;
bool _isInitialized = false;
///
void initialize(TelemetryService telemetryService) {
if (_isInitialized) return;
_telemetryService = telemetryService;
WidgetsBinding.instance.addObserver(this);
//
_handleForeground();
_isInitialized = true;
debugPrint('📱 [Session] Manager initialized');
}
///
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_isInitialized = false;
_instance = null;
debugPrint('📱 [Session] Manager disposed');
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
_handleForeground();
break;
case AppLifecycleState.paused:
_handleBackground();
break;
case AppLifecycleState.inactive:
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
//
break;
}
}
///
void _handleForeground() {
if (_state == SessionState.foreground) return;
_state = SessionState.foreground;
_startNewSession();
// + session_start
// Amplitude/Mixpanel
_telemetryService?.flushOnBackground();
}
///
void _handleBackground() {
if (_state == SessionState.background) return;
_state = SessionState.background;
_endCurrentSession();
// session_end 10
_telemetryService?.flushOnBackground();
}
///
void _startNewSession() {
// sessionId
_currentSessionId = const Uuid().v4();
_sessionStartTime = DateTime.now();
// app_session_start DAU
_telemetryService?.logEvent(
SessionEvents.sessionStart,
type: EventType.session,
level: EventLevel.info,
properties: {
'session_id': _currentSessionId,
},
);
// HeartbeatService
onSessionStart?.call();
debugPrint('📱 [Session] Started: $_currentSessionId');
}
///
void _endCurrentSession() {
if (_currentSessionId == null) return;
final duration = _sessionStartTime != null
? DateTime.now().difference(_sessionStartTime!).inSeconds
: 0;
// app_session_end
_telemetryService?.logEvent(
SessionEvents.sessionEnd,
type: EventType.session,
level: EventLevel.info,
properties: {
'session_id': _currentSessionId,
'duration_seconds': duration,
},
);
// HeartbeatService
onSessionEnd?.call();
debugPrint('📱 [Session] Ended: $_currentSessionId (${duration}s)');
_currentSessionId = null;
_sessionStartTime = null;
}
///
int get sessionDurationSeconds {
if (_sessionStartTime == null) return 0;
return DateTime.now().difference(_sessionStartTime!).inSeconds;
}
}

View File

@ -0,0 +1,133 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/foundation.dart';
import '../models/telemetry_event.dart';
///
///
class TelemetryStorage {
static const String _keyEventQueue = 'telemetry_event_queue';
static const String _keyDeviceContext = 'telemetry_device_context';
static const String _keyInstallId = 'telemetry_install_id';
static const int _maxQueueSize = 500; // 500
late SharedPreferences _prefs;
bool _isInitialized = false;
///
Future<void> init() async {
if (_isInitialized) return;
_prefs = await SharedPreferences.getInstance();
_isInitialized = true;
}
///
Future<void> saveDeviceContext(Map<String, dynamic> context) async {
await _prefs.setString(_keyDeviceContext, jsonEncode(context));
}
///
Map<String, dynamic>? getDeviceContext() {
final str = _prefs.getString(_keyDeviceContext);
if (str == null) return null;
return jsonDecode(str) as Map<String, dynamic>;
}
/// InstallId
Future<void> saveInstallId(String installId) async {
await _prefs.setString(_keyInstallId, installId);
}
/// InstallId
String? getInstallId() {
return _prefs.getString(_keyInstallId);
}
///
Future<void> enqueueEvent(TelemetryEvent event) async {
final queue = _getEventQueue();
//
if (queue.length >= _maxQueueSize) {
queue.removeAt(0); //
debugPrint('⚠️ Telemetry queue full, removed oldest event');
}
queue.add(event.toJson());
await _saveEventQueue(queue);
}
///
Future<void> enqueueEvents(List<TelemetryEvent> events) async {
final queue = _getEventQueue();
for (var event in events) {
if (queue.length >= _maxQueueSize) break;
queue.add(event.toJson());
}
await _saveEventQueue(queue);
}
/// (N条)
List<TelemetryEvent> dequeueEvents(int limit) {
final queue = _getEventQueue();
final count = queue.length > limit ? limit : queue.length;
final events = queue
.take(count)
.map((json) => TelemetryEvent.fromJson(json))
.toList();
return events;
}
///
Future<void> removeEvents(int count) async {
final queue = _getEventQueue();
if (count >= queue.length) {
await clearEventQueue();
} else {
queue.removeRange(0, count);
await _saveEventQueue(queue);
}
}
///
int getQueueSize() {
return _getEventQueue().length;
}
///
Future<void> clearEventQueue() async {
await _prefs.remove(_keyEventQueue);
}
/// 退
/// installId deviceContext
Future<void> clearUserData() async {
if (!_isInitialized) {
await init();
}
await _prefs.remove(_keyEventQueue);
debugPrint('📊 TelemetryStorage: 已清除用户相关遥测数据');
}
//
List<Map<String, dynamic>> _getEventQueue() {
final str = _prefs.getString(_keyEventQueue);
if (str == null) return [];
try {
final List<dynamic> list = jsonDecode(str);
return list.cast<Map<String, dynamic>>();
} catch (e) {
debugPrint('⚠️ Failed to parse event queue: $e');
return [];
}
}
Future<void> _saveEventQueue(List<Map<String, dynamic>> queue) async {
await _prefs.setString(_keyEventQueue, jsonEncode(queue));
}
}

View File

@ -0,0 +1,396 @@
import 'dart:async';
import 'dart:math';
import 'package:uuid/uuid.dart';
import 'package:flutter/material.dart';
import 'models/telemetry_event.dart';
import 'models/device_context.dart';
import 'models/telemetry_config.dart';
import 'collectors/device_info_collector.dart';
import 'storage/telemetry_storage.dart';
import 'uploader/telemetry_uploader.dart';
import 'session/session_manager.dart';
import 'presence/heartbeat_service.dart';
import 'presence/presence_config.dart';
///
///
class TelemetryService {
static TelemetryService? _instance;
TelemetryService._();
factory TelemetryService() {
_instance ??= TelemetryService._();
return _instance!;
}
final _storage = TelemetryStorage();
late TelemetryUploader _uploader;
DeviceContext? _deviceContext;
/// IDDAU去重
late String _installId;
String get installId => _installId;
/// ID
String? _userId;
String? get userId => _userId;
/// 访/
String? _accessToken;
/// API
late String _apiBaseUrl;
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
///
late SessionManager _sessionManager;
///
late HeartbeatService _heartbeatService;
Timer? _configSyncTimer;
/// (main.dart中调用)
Future<void> initialize({
required String apiBaseUrl,
required BuildContext context,
String? userId,
Duration configSyncInterval = const Duration(hours: 1),
PresenceConfig? presenceConfig,
}) async {
if (_isInitialized) return;
_apiBaseUrl = apiBaseUrl;
// 1.
await _storage.init();
// 2. installId
await _initInstallId();
// 3.
await TelemetryConfig().loadUserOptIn();
// 4.
// 使
TelemetryConfig().syncFromRemote(apiBaseUrl).catchError((e) {
debugPrint('📊 [Telemetry] Remote config sync failed (non-blocking): $e');
});
// 5.
_deviceContext = await DeviceInfoCollector().collect(context);
await _storage.saveDeviceContext(_deviceContext!.toJson());
// 6. ID
_userId = userId;
// 7.
_uploader = TelemetryUploader(
apiBaseUrl: apiBaseUrl,
storage: _storage,
getAuthHeaders: _getAuthHeaders,
);
// 8.
if (TelemetryConfig().globalEnabled) {
_uploader.startPeriodicUpload();
}
// 9.
_configSyncTimer = Timer.periodic(configSyncInterval, (_) async {
await TelemetryConfig().syncFromRemote(apiBaseUrl);
//
if (TelemetryConfig().globalEnabled) {
_uploader.startPeriodicUpload();
} else {
_uploader.stopPeriodicUpload();
}
//
if (TelemetryConfig().presenceConfig != null) {
_heartbeatService.updateConfig(TelemetryConfig().presenceConfig!);
}
});
// 10.
_sessionManager = SessionManager();
_sessionManager.initialize(this);
// 11.
_heartbeatService = HeartbeatService();
_heartbeatService.initialize(
apiBaseUrl: apiBaseUrl,
config: presenceConfig ?? TelemetryConfig().presenceConfig,
getInstallId: () => _installId,
getUserId: () => _userId,
getAppVersion: () => _deviceContext?.appVersion ?? 'unknown',
getAuthHeaders: _getAuthHeaders,
);
_isInitialized = true;
debugPrint('📊 TelemetryService initialized');
debugPrint(' InstallId: $_installId');
debugPrint(' UserId: $_userId');
}
/// installId
Future<void> _initInstallId() async {
final storedId = _storage.getInstallId();
if (storedId != null) {
_installId = storedId;
} else {
_installId = const Uuid().v4();
await _storage.saveInstallId(_installId);
}
debugPrint('📊 [Telemetry] Install ID: $_installId');
}
/// 使 access token AccountService
Map<String, String> _getAuthHeaders() {
if (_accessToken != null) {
return {'Authorization': 'Bearer $_accessToken'};
}
return {};
}
/// ()
void logEvent(
String eventName, {
EventType type = EventType.userAction,
EventLevel level = EventLevel.info,
Map<String, dynamic>? properties,
}) {
if (!_isInitialized) {
debugPrint('⚠️ TelemetryService not initialized, event ignored');
return;
}
//
if (!TelemetryConfig().shouldLog(type, eventName)) {
return; //
}
//
if (_needsSampling(type)) {
if (Random().nextDouble() > TelemetryConfig().samplingRate) {
return; //
}
}
final deviceProps = _deviceContext != null
? {
'device_brand': _deviceContext!.brand,
'device_model': _deviceContext!.model,
'device_os': _deviceContext!.osVersion,
'app_version': _deviceContext!.appVersion,
'locale': _deviceContext!.locale,
}
: <String, dynamic>{};
final event = TelemetryEvent(
eventId: const Uuid().v4(),
type: type,
level: level,
name: eventName,
properties: {...deviceProps, ...?properties},
timestamp: DateTime.now(),
userId: _userId,
sessionId: _sessionManager.currentSessionId,
installId: _installId,
deviceContextId: _deviceContext?.androidId ?? '',
);
_storage.enqueueEvent(event);
//
_uploader.uploadIfNeeded();
}
///
bool _needsSampling(EventType type) {
// 100%
return type != EventType.error &&
type != EventType.crash &&
type != EventType.session;
}
/// 访
void logPageView(String pageName, {Map<String, dynamic>? extra}) {
logEvent(
'page_view',
type: EventType.pageView,
properties: {'page': pageName, ...?extra},
);
}
///
void logUserAction(String action, {Map<String, dynamic>? properties}) {
logEvent(
action,
type: EventType.userAction,
properties: properties,
);
}
///
void logError(
String errorMessage, {
Object? error,
StackTrace? stackTrace,
Map<String, dynamic>? extra,
}) {
logEvent(
'error_occurred',
type: EventType.error,
level: EventLevel.error,
properties: {
'message': errorMessage,
'error': error?.toString(),
'stack_trace': stackTrace?.toString(),
...?extra,
},
);
}
/// API调用
void logApiCall({
required String url,
required String method,
required int statusCode,
required int durationMs,
String? error,
}) {
logEvent(
'api_call',
type: EventType.apiCall,
level: error != null ? EventLevel.error : EventLevel.info,
properties: {
'url': url,
'method': method,
'status_code': statusCode,
'duration_ms': durationMs,
'error': error,
},
);
}
///
void logPerformance(
String metricName, {
required int durationMs,
Map<String, dynamic>? extra,
}) {
logEvent(
metricName,
type: EventType.performance,
properties: {'duration_ms': durationMs, ...?extra},
);
}
/// ID()
void setUserId(String? userId) {
_userId = userId;
debugPrint('📊 [Telemetry] User ID set: $userId');
}
/// ID(退)
void clearUserId() {
_userId = null;
debugPrint('📊 [Telemetry] User ID cleared');
}
/// 访/
void setAccessToken(String? token) {
_accessToken = token;
debugPrint('📊 [Telemetry] Access token ${token != null ? 'set' : 'cleared'}');
}
/// 访退
void clearAccessToken() {
_accessToken = null;
}
/// 退
///
Future<void> pauseForLogout() async {
_uploader.stopPeriodicUpload();
await _storage.clearEventQueue();
_userId = null;
debugPrint('📊 [Telemetry] Paused for logout');
}
///
void resumeAfterLogin() {
if (TelemetryConfig().globalEnabled) {
_uploader.startPeriodicUpload();
debugPrint('📊 [Telemetry] Resumed after login');
}
}
///
Future<void> setUserOptIn(bool optIn) async {
await TelemetryConfig().setUserOptIn(optIn);
if (!optIn) {
//
_uploader.stopPeriodicUpload();
await _storage.clearEventQueue();
debugPrint('📊 Telemetry disabled by user');
} else {
//
if (TelemetryConfig().globalEnabled) {
_uploader.startPeriodicUpload();
}
debugPrint('📊 Telemetry enabled by user');
}
}
// ========== 线 ==========
/// ID
String? get currentSessionId => _sessionManager.currentSessionId;
///
int get sessionDurationSeconds => _sessionManager.sessionDurationSeconds;
///
bool get isHeartbeatRunning => _heartbeatService.isRunning;
///
int get heartbeatCount => _heartbeatService.heartbeatCount;
///
void updatePresenceConfig(PresenceConfig config) {
_heartbeatService.updateConfig(config);
}
///
DeviceContext? get deviceContext => _deviceContext;
/// App进入后台时调用10
Future<void> flushOnBackground() async {
await _uploader.uploadBatch(batchSize: 50);
debugPrint('📊 [Telemetry] Flushed on background');
}
/// App退出前调用
Future<void> dispose() async {
_configSyncTimer?.cancel();
_sessionManager.dispose();
_heartbeatService.dispose();
await _uploader.forceUploadAll();
_isInitialized = false;
debugPrint('📊 TelemetryService disposed');
}
///
static void reset() {
_instance = null;
}
}

View File

@ -0,0 +1,115 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../storage/telemetry_storage.dart';
///
///
class TelemetryUploader {
final String apiBaseUrl;
final TelemetryStorage storage;
final Dio _dio;
Timer? _uploadTimer;
bool _isUploading = false;
///
Map<String, String> Function()? getAuthHeaders;
TelemetryUploader({
required this.apiBaseUrl,
required this.storage,
this.getAuthHeaders,
}) : _dio = Dio(BaseOptions(
baseUrl: apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
/// (3020)
void startPeriodicUpload({
Duration interval = const Duration(seconds: 30),
int batchSize = 20,
}) {
_uploadTimer?.cancel();
_uploadTimer = Timer.periodic(interval, (_) {
uploadIfNeeded(batchSize: batchSize);
});
debugPrint('📊 Telemetry uploader started (interval: ${interval.inSeconds}s)');
}
///
void stopPeriodicUpload() {
_uploadTimer?.cancel();
_uploadTimer = null;
debugPrint('📊 Telemetry uploader stopped');
}
/// ()
Future<void> uploadIfNeeded({int batchSize = 20}) async {
if (_isUploading) return;
final queueSize = storage.getQueueSize();
if (queueSize < 10) return; // 10,
await uploadBatch(batchSize: batchSize);
}
///
Future<bool> uploadBatch({int batchSize = 20}) async {
if (_isUploading) return false;
_isUploading = true;
try {
final events = storage.dequeueEvents(batchSize);
if (events.isEmpty) return true;
// API (使 toServerJson )
final response = await _dio.post(
'/api/v1/telemetry/events',
data: {
'events': events.map((e) => e.toServerJson()).toList(),
},
options: Options(
headers: getAuthHeaders?.call(),
),
);
if (response.statusCode == 200) {
// ,
await storage.removeEvents(events.length);
debugPrint('✅ Uploaded ${events.length} telemetry events');
return true;
} else {
debugPrint('❌ Upload failed: ${response.statusCode}');
return false;
}
} on DioException catch (e) {
debugPrint('❌ Upload error (DioException): ${e.message}');
return false;
} catch (e) {
debugPrint('❌ Upload error: $e');
return false;
} finally {
_isUploading = false;
}
}
/// (app退出前调用)
Future<void> forceUploadAll() async {
stopPeriodicUpload();
int retries = 0;
while (storage.getQueueSize() > 0 && retries < 3) {
final success = await uploadBatch(batchSize: 50);
if (!success) {
retries++;
await Future.delayed(const Duration(seconds: 1));
}
}
if (storage.getQueueSize() > 0) {
debugPrint('⚠️ ${storage.getQueueSize()} events remaining in queue');
}
}
}

View File

@ -8,6 +8,7 @@ import 'app/i18n/app_localizations.dart';
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/updater/models/update_config.dart';
import 'core/push/push_service.dart';
import 'core/providers/notification_badge_manager.dart';
@ -108,6 +109,19 @@ class _GenexConsumerAppState extends ConsumerState<GenexConsumerApp> {
ref.read(authProvider.notifier).restoreSession();
});
}
// BuildContext
WidgetsBinding.instance.addPostFrameCallback((_) async {
final auth = AuthService.instance.authState.value;
await TelemetryService().initialize(
apiBaseUrl: 'https://api.gogenex.com',
context: context,
userId: auth?.user['id'] as String?,
);
if (auth != null) {
TelemetryService().setAccessToken(auth.accessToken);
}
});
}
@override

View File

@ -14,6 +14,9 @@ dependencies:
intl: any
dio: ^5.4.3+1
package_info_plus: ^8.0.0
uuid: ^4.3.3
equatable: ^2.0.5
device_info_plus: ^10.1.0
path_provider: ^2.1.0
crypto: ^3.0.3
permission_handler: ^11.3.1