Files
social-app/docs/plans/2026-04-01-analytics-design.md
T

694 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 统一事件信封结构
所有埋点事件使用统一顶层结构:
```json
{
"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...` | 事件唯一 IDULID/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 规范
通用环境上下文:
```json
{
"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
```json
{
"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
```json
{
"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
```json
{
"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
```json
{
"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
```json
{
"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 请求体
```json
{
"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 单行格式
每行一个完整事件对象:
```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 类型定义
```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 核心流程
1. **事件采集**:调用 `AnalyticsTracker.track(event)` 记录事件
2. **队列缓冲**:事件先入内存队列
3. **批量发送**:队列满(50条)或定时(30秒)触发批量发送
4. **失败重试**:发送失败则落盘本地,下次启动时重试
### 4.3 初始化
`apps/lib/main.dart` 的 App 初始化阶段:
```dart
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 请求格式
```json
{
"events": [
{ /* */ },
{ /* */ }
]
}
```
### 5.3 响应格式
```json
{
"received": 10,
"queued": true
}
```
### 5.4 异步写入流程
```
请求 → 内存队列缓冲 → [队列满100条 或 超时5秒] → taskiq 任务 → 批量写入文件
```
- API 接收事件后立即放入内存队列,返回 202 Accepted
- 后台 taskiq worker 消费队列,批量写入 JSONL 文件
- 使用文件锁保证多 worker 并发写入安全
### 5.5 Taskiq 任务定义
```python
@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 静态文件目录
```python
from fastapi.staticfiles import StaticFiles
app.mount("/analytics", StaticFiles(directory="web/dist", html=True), name="analytics")
```
### 6.4 认证方式
**后端验证密码,返回简单 Token(HMAC)**
1. 前端登录页输入密码
2. 调用 `POST /api/v1/analytics/login` 验证
3. 后端读取 `.env``ANALYTICS_PASSWORD` 验证
4. 验证成功返回 HMAC Token(5分钟有效)和数据读取基地址,前端存 sessionStorage
5. 后续请求带 Bearer Token,后端验证后返回对应日期 JSONL 内容
### 6.5 页面设计
**登录页 (`/login`)**
- 简单密码输入框
- 调用后端 API 验证
- 登录成功后写入 sessionStorage
**仪表盘 (`/dashboard`)**
- 顶部:日期范围选择器(默认最近7天)
- 统计卡片:
- DAU(当日独立用户数)
- 累计对话次数
- 累计页面点击量
- 平均停留时长
- 图表:
- 折线图:每日登录趋势
- 柱状图:各页面点击量 Top 10
- 热力图:页面停留时长分布
### 6.6 数据读取
- 前端登录成功后获取 `data_base_url`(当前为 `/api/v1/analytics/data`
- 前端按日期请求 `GET /api/v1/analytics/data/{YYYY-MM-DD}` 获取 JSONL 文本并在页面聚合
- 后端读取 `backend/data/analytics/*.jsonl` 原始数据返回
---
## 7. 环境变量
### 7.1 backend/.env 新增
```env
# Analytics 数据存储路径
SOCIAL_ANALYTICS__DATA_PATH=backend/data/analytics
# 可视化网站密码
SOCIAL_ANALYTICS__PASSWORD=your-secure-password
```
### 7.2 后端登录验证 API
**端点:** `POST /api/v1/analytics/login`
**请求:**
```json
{
"password": "your-password"
}
```
**响应(成功):**
```json
{
"success": true,
"token": "signed-token",
"data_base_url": "/api/v1/analytics/data"
}
```
**响应(失败):**
```json
{
"error": "invalid_password"
}
```
### 7.3 Web 环境变量
web 构建时可通过环境变量注入 API 地址:
```env
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 环境变量改动
```env
# 当前
SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY=1
# 改为
SOCIAL_WORKER__GROUPS__GENERAL__CONCURRENCY=1
```
涉及文件:
- `.env`
- `.env.example`
- `deploy/.env.prod`
- `deploy/.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 查询
- 支持数据导出功能