18 KiB
18 KiB
Analytics 数据埋点与可视化方案
1. 概述
为 Flutter 移动应用实现数据埋点系统,采集用户行为数据,通过异步方式发送至后端存储,并提供可视化网站展示。
目标
- 监测每日用户登录时间
- 记录 Agent 对话次数
- 统计每个页面的点击次数和停留时长
- 统一数据格式,参照 OpenTelemetry 简化版规范
- 数据持久化到本地文件,供可视化网站读取
2. 系统架构
┌─────────────────┐ 异步 POST ┌─────────────────┐ taskiq ┌─────────────────────────┐
│ Flutter App │ ───────────────► │ FastAPI │ ──────────► │ backend/data/analytics/ │
│ (埋点 SDK) │ /v1/analytics │ /v1/analytics │ async │ {YYYY-MM-DD}.jsonl │
└─────────────────┘ └─────────────────┘ └─────────────────────────┘
│ │
│ │ taskiq worker (general queue)
▼ ▼
┌─────────────────┐
│ Redis Queue │
└─────────────────┘
3. 数据格式规范
3.1 统一事件信封结构
所有埋点事件使用统一顶层结构:
{
"event_id": "01JQ2G5Q3N6Y5N8R7M4K2P1T9A",
"event_type": "session.login",
"timestamp": "2026-04-01T10:30:00.123Z",
"user_id": "user_123",
"device_id": "install_a93f5d7c21",
"session_id": "sess_7c2d5e8f91",
"platform": "android",
"app_version": "1.0.0",
"app_build": "100",
"env": "prod",
"page_name": "login",
"trace_id": "trace_8d2f6c1a",
"request_id": null,
"attributes": {},
"metrics": {},
"context": {
"network_type": "wifi",
"os_version": "Android 14",
"device_model": "Xiaomi 13",
"locale": "zh-CN",
"timezone": "Asia/Taipei"
}
}
3.2 顶层字段定义
| 字段 | 类型 | 必填 | 示例 | 说明 |
|---|---|---|---|---|
event_id |
string | 是 | 01JQ2G... |
事件唯一 ID,ULID/UUID,用于幂等去重 |
event_type |
string | 是 | page.view |
事件类型,dot 命名法 |
timestamp |
string | 是 | 2026-04-01T10:30:00.123Z |
事件发生时间,UTC ISO8601 |
user_id |
string | 是 | user_123 |
用户 ID(必填) |
device_id |
string | 是 | install_xxx |
设备标识 |
session_id |
string | 是 | sess_xxx |
App 一次启动会话 ID |
platform |
string | 是 | android |
android / ios / web |
app_version |
string | 是 | 1.0.0 |
App 版本号 |
app_build |
string | null | 否 | 100 |
构建号 |
env |
string | 是 | prod |
dev / staging / prod |
page_name |
string | null | 否 | home |
当前页面名 |
trace_id |
string | null | 否 | trace_xxx |
用于串联日志、接口、错误 |
request_id |
string | null | 否 | req_xxx |
与某次请求关联时可传 |
attributes |
object | 是 | {} |
离散属性、分类信息 |
metrics |
object | 是 | {} |
可聚合数值指标 |
context |
object | 否 | {...} |
客户端环境上下文 |
3.3 attributes 规范
只放离散属性、分类信息、上下文标签。
允许内容: string, boolean, number, null, 一层简单 object 或 string list
适合放这里:
method: "password"page_from: "home"conversation_id: "conv_123"element_id: "send_button"logout_reason: "manual"
不适合放这里(应进 metrics):
- 停留时长、点击次数、响应时间、消息数、会话时长
3.4 metrics 规范
只放数值型、可聚合、可统计指标。
允许内容: int, float
适合放这里:
stay_duration_ms: 停留时长(毫秒)click_count: 点击次数response_time_ms: 响应耗时message_count: 消息数量session_duration_s: 会话时长(秒)
3.5 context 规范
通用环境上下文:
{
"network_type": "wifi",
"os_version": "Android 14",
"device_model": "Xiaomi 13",
"locale": "zh-CN",
"timezone": "Asia/Taipei"
}
| 字段 | 类型 | 说明 |
|---|---|---|
network_type |
string | null | wifi / cellular / offline / unknown |
os_version |
string | null | 操作系统版本 |
device_model |
string | null | 设备型号 |
locale |
string | null | 当前语言地区 |
timezone |
string | null | 时区标识 |
3.6 事件类型定义
| event_type | 触发时机 | attributes | metrics |
|---|---|---|---|
session.login |
用户登录成功 | method (password/phone_code/oauth) |
- |
session.logout |
用户登出 | reason (manual/expired/kickout) |
session_duration_s |
agent.chat_completed |
Agent 对话完成 | conversation_id, scenario |
message_count, response_time_ms |
page.view |
页面退出时 | page_from |
stay_duration_ms, click_count |
ui.click |
元素点击时 | element_id, element_type |
- |
3.7 事件数据结构
session.login
{
"event_id": "01JQ2G5Q3N6Y5N8R7M4K2P1T9A",
"event_type": "session.login",
"timestamp": "2026-04-01T10:30:00.123Z",
"user_id": "user_123",
"device_id": "install_a93f5d7c21",
"session_id": "sess_7c2d5e8f91",
"platform": "android",
"app_version": "1.0.0",
"app_build": "100",
"env": "prod",
"page_name": "login",
"trace_id": "trace_login_001",
"request_id": "req_login_001",
"attributes": { "method": "password" },
"metrics": {},
"context": { "network_type": "wifi", "os_version": "Android 14", "device_model": "Xiaomi 13", "locale": "zh-CN", "timezone": "Asia/Taipei" }
}
session.logout
{
"event_id": "01JQ2G6A7X9N2T4R8K1M5P3Q7B",
"event_type": "session.logout",
"timestamp": "2026-04-01T12:00:00.000Z",
"user_id": "user_123",
"device_id": "install_a93f5d7c21",
"session_id": "sess_7c2d5e8f91",
"platform": "android",
"app_version": "1.0.0",
"app_build": "100",
"env": "prod",
"page_name": "settings",
"trace_id": "trace_logout_001",
"request_id": null,
"attributes": { "reason": "manual" },
"metrics": { "session_duration_s": 5400 },
"context": { "network_type": "wifi", "os_version": "Android 14", "device_model": "Xiaomi 13", "locale": "zh-CN", "timezone": "Asia/Taipei" }
}
agent.chat_completed
{
"event_id": "01JQ2G7B9V4K8N3R1T6M2P5Q8C",
"event_type": "agent.chat_completed",
"timestamp": "2026-04-01T13:20:15.456Z",
"user_id": "user_123",
"device_id": "install_a93f5d7c21",
"session_id": "sess_7c2d5e8f91",
"platform": "android",
"app_version": "1.0.0",
"app_build": "100",
"env": "prod",
"page_name": "chat",
"trace_id": "trace_agent_001",
"request_id": "req_agent_001",
"attributes": { "conversation_id": "conv_987", "scenario": "assistant" },
"metrics": { "message_count": 4, "response_time_ms": 1320 },
"context": { "network_type": "wifi", "os_version": "Android 14", "device_model": "Xiaomi 13", "locale": "zh-CN", "timezone": "Asia/Taipei" }
}
page.view
{
"event_id": "01JQ2G8C1M7R4T9K2P6N5Q3D8E",
"event_type": "page.view",
"timestamp": "2026-04-01T14:05:30.000Z",
"user_id": "user_123",
"device_id": "install_a93f5d7c21",
"session_id": "sess_7c2d5e8f91",
"platform": "android",
"app_version": "1.0.0",
"app_build": "100",
"env": "prod",
"page_name": "home",
"trace_id": "trace_page_home_001",
"request_id": null,
"attributes": { "page_from": "login" },
"metrics": { "stay_duration_ms": 18234, "click_count": 7 },
"context": { "network_type": "wifi", "os_version": "Android 14", "device_model": "Xiaomi 13", "locale": "zh-CN", "timezone": "Asia/Taipei" }
}
ui.click
{
"event_id": "01JQ2G9D4P8N1R6T3K5M7Q2E9F",
"event_type": "ui.click",
"timestamp": "2026-04-01T14:00:12.000Z",
"user_id": "user_123",
"device_id": "install_a93f5d7c21",
"session_id": "sess_7c2d5e8f91",
"platform": "android",
"app_version": "1.0.0",
"app_build": "100",
"env": "prod",
"page_name": "home",
"trace_id": "trace_click_001",
"request_id": null,
"attributes": { "element_id": "create_task_button", "element_type": "button" },
"metrics": {},
"context": { "network_type": "wifi", "os_version": "Android 14", "device_model": "Xiaomi 13", "locale": "zh-CN", "timezone": "Asia/Taipei" }
}
4. 批量上报请求结构
4.1 请求体
{
"client_time": "2026-04-01T14:10:00.000Z",
"sdk_version": "1.0.0",
"events": [
{ /* 事件对象 */ }
]
}
4.2 字段定义
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
client_time |
string | 否 | 本次上报请求发起时间 |
sdk_version |
string | 否 | 埋点 SDK 版本 |
events |
array | 是 | 事件数组,建议 1~100 条 |
5. 后端落盘结构
5.1 路径结构
按日期分桶,便于后续聚合查询:
backend/data/analytics/
├── 2026-04-01.jsonl
├── 2026-04-02.jsonl
└── 2026-04-03.jsonl
5.2 JSONL 单行格式
每行一个完整事件对象:
{"event_id":"01JQ2G5Q3N6Y5N8R7M4K2P1T9A","event_type":"session.login","timestamp":"2026-04-01T10:30:00.123Z",...}
{"event_id":"01JQ2G6A7X9N2T4R8K1M5P3Q7B","event_type":"session.logout","timestamp":"2026-04-01T12:00:00.000Z",...}
6. Python 类型定义
from typing import Any, Literal
from pydantic import BaseModel, Field
from datetime import datetime
class AnalyticsContext(BaseModel):
network_type: str | None = None
os_version: str | None = None
device_model: str | None = None
locale: str | None = None
timezone: str | None = None
class AnalyticsEvent(BaseModel):
event_id: str
event_type: str
timestamp: datetime
user_id: str
device_id: str
session_id: str
platform: Literal["android", "ios", "web"]
app_version: str
app_build: str | None = None
env: Literal["dev", "staging", "prod"]
page_name: str | None = None
trace_id: str | None = None
request_id: str | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
metrics: dict[str, int | float] = Field(default_factory=dict)
context: AnalyticsContext | None = None
class AnalyticsBatchRequest(BaseModel):
client_time: datetime | None = None
sdk_version: str | None = None
events: list[AnalyticsEvent]
7. Flutter 埋点 SDK 设计
4.1 目录结构
apps/lib/core/analytics/
├── tracker.dart # 埋点入口,单例
├── config.dart # 配置(endpoint、flush 策略)
├── events/ # 事件定义
│ ├── base_event.dart # 基础事件类
│ ├── login_event.dart
│ ├── logout_event.dart
│ ├── conversation_event.dart
│ └── page_view_event.dart
├── queue/
│ └── event_queue.dart # 内存队列
└── sender.dart # 异步 HTTP 发送器
4.2 核心流程
- 事件采集:调用
AnalyticsTracker.track(event)记录事件 - 队列缓冲:事件先入内存队列
- 批量发送:队列满(50条)或定时(30秒)触发批量发送
- 失败重试:发送失败则落盘本地,下次启动时重试
4.3 初始化
在 apps/lib/main.dart 的 App 初始化阶段:
await AnalyticsTracker.init(
endpoint: '${Env.apiBaseUrl}/v1/analytics/events',
deviceId: deviceInfo.id,
);
4.4 页面停留时长计算
page.view事件在页面initState时发送enter_time- 在页面
dispose时补充stay_duration_ms - 点击次数由各页面手动调用
trackClick(pageName, elementId)
4.5 Agent 对话统计
- 在 Agent 对话完成回调中触发
agent.conversation事件 message_count为用户发送的消息数response_time_ms为首次响应耗时
5. 后端接收路由
5.1 路由定义
端点: POST /v1/analytics/events
5.2 请求格式
{
"events": [
{ /* 事件对象 */ },
{ /* 事件对象 */ }
]
}
5.3 响应格式
{
"received": 10,
"queued": true
}
5.4 异步写入流程
请求 → 内存队列缓冲 → [队列满100条 或 超时5秒] → taskiq 任务 → 批量写入文件
- API 接收事件后立即放入内存队列,返回 202 Accepted
- 后台 taskiq worker 消费队列,批量写入 JSONL 文件
- 使用文件锁保证多 worker 并发写入安全
5.5 Taskiq 任务定义
@taskify
async def write_analytics_events(batch: list[AnalyticsEvent], date: str):
"""批量写入事件到 JSONL 文件"""
# 按 event_type 分组写入对应文件
# 每条事件追加到当日文件末尾
5.6 文件写入
路径:backend/data/analytics/{event_type}/{YYYY-MM-DD}.jsonl
写入策略:
- 每条事件追加到当日文件末尾
- 使用文件锁保证并发写入安全
- 目录不存在时自动创建
5.7 目录结构
backend/data/analytics/
├── session.login/
│ ├── 2026-04-01.jsonl
│ └── 2026-04-02.jsonl
├── session.logout/
│ └── 2026-04-01.jsonl
├── agent.conversation/
│ └── 2026-04-01.jsonl
└── page.view/
└── 2026-04-01.jsonl
6. 可视化网站
6.1 技术栈
- 框架:React 18 + Vite
- 图表:ECharts
- 样式:TailwindCSS
- HTTP:Axios
6.2 项目结构
web/
├── src/
│ ├── main.jsx
│ ├── App.jsx
│ ├── pages/
│ │ ├── Login.jsx
│ │ └── Dashboard.jsx
│ ├── components/
│ │ ├── StatCard.jsx
│ │ ├── LoginTrendChart.jsx
│ │ ├── PageClickChart.jsx
│ │ └── StayDurationChart.jsx
│ ├── services/
│ │ └── api.js
│ └── styles/
├── public/
├── package.json
├── vite.config.js
└── .env
6.3 访问方式
与后端打包在一起,通过子路径访问:
http://域名/analytics/ # 静态网站
http://域名/api/v1/analytics/ # API 路由
实现方式:FastAPI mount 静态文件目录
from fastapi.staticfiles import StaticFiles
app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="analytics")
6.4 认证方式
推荐:后端验证密码,返回 JWT Token
- 前端登录页输入密码
- 调用
POST /api/v1/analytics/login验证 - 后端读取
.env中ANALYTICS_PASSWORD验证 - 验证成功返回 JWT Token,前端存 sessionStorage
- 后续请求带 Token,后端验证
6.5 页面设计
登录页 (/login)
- 简单密码输入框
- 调用后端 API 验证
- 登录成功后写入 sessionStorage
仪表盘 (/dashboard)
- 顶部:日期范围选择器(默认最近7天)
- 统计卡片:
- DAU(当日独立用户数)
- 累计对话次数
- 累计页面点击量
- 平均停留时长
- 图表:
- 折线图:每日登录趋势
- 柱状图:各页面点击量 Top 10
- 热力图:页面停留时长分布
6.6 数据读取
- 前端通过
GET /api/v1/analytics/summary获取聚合数据 - 后端解析
backend/data/analytics/*.jsonl文件并聚合 - 提供
GET /api/v1/analytics/daily等查询接口
7. 环境变量
7.1 backend/.env 新增
# Analytics 数据存储路径
ANALYTICS_DATA_PATH=backend/data/analytics
# 可视化网站密码(用于 /api/v1/analytics/login 验证)
ANALYTICS_PASSWORD=your-secure-password
7.2 后端登录验证 API
端点: POST /api/v1/analytics/login
请求:
{
"password": "your-password"
}
响应(成功):
{
"token": "jwt-token-here"
}
响应(失败):
{
"error": "invalid_password"
}
7.3 Web 环境变量
web 构建时可通过环境变量注入 API 地址:
VITE_API_BASE_URL=http://localhost:5775
注:密码不在前端存储,改为后端验证方式。
8. Worker Queue 重构
8.1 背景
现有 automation 队列仅用于将任务转发到 agent 队列,职责单一。为支持 analytics 异步写入,将 automation 重构为 general,用于承载所有非实时任务。
8.2 重构范围
| 文件 | 改动内容 |
|---|---|
backend/src/core/taskiq/app.py |
worker_automation_broker → worker_general_broker,queue name "automation" → "general" |
backend/src/core/taskiq/__init__.py |
更新 export 名称 |
backend/src/core/agentscope/runtime/tasks.py |
import 和 @worker_automation_broker.task → @worker_general_broker.task |
backend/src/v1/agent/service.py:145 |
queue = "automation" → queue = "general" |
backend/tests/unit/core/taskiq/test_app.py |
更新引用 |
infra/scripts/app.sh |
WORKER_AUTOMATION_CMD → WORKER_GENERAL_CMD,tmux window 改名 |
deploy/docker-compose.prod.yml |
worker-automation service → worker-general |
8.3 环境变量改动
# 当前
SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY=1
# 改为
SOCIAL_WORKER__GROUPS__GENERAL__CONCURRENCY=1
涉及文件:
.env.env.exampledeploy/.env.proddeploy/.env.prod.example
9. 实施计划
| 阶段 | 任务 | 产出 |
|---|---|---|
| 0 | Worker Queue 重构 (automation → general) |
所有 queue 相关文件 |
| 1 | 创建 docs/plans/2026-04-01-analytics-design.md |
设计文档 |
| 2 | 实现后端 POST /v1/analytics/events 路由 |
backend/src/v1/analytics/ |
| 3 | 实现 Flutter 埋点 SDK | apps/lib/core/analytics/ |
| 4 | 集成埋点到现有 App 页面 | 修改 home_screen、auth 等 |
| 5 | 搭建 React 项目框架 | web/ |
| 6 | 实现 Dashboard 页面和图表 | ECharts 组件 |
| 7 | 端到端测试验证 | 测试报告 |
10. 风险与限制
- 数据丢失:批量发送失败时落盘本地,最多保留次日重试
- 并发写入:多实例部署时需使用分布式锁,当前设计仅支持单实例
- 密码安全:简单密码校验,生产环境建议使用 JWT
11. 后续扩展方向
- 支持更多事件类型(分享、搜索、错误)
- 接入 OpenTelemetry Collector
- 接入 ClickHouse 进行 OLAP 查询
- 支持数据导出功能