light-sentry-sdk/docs/03-data-aggregation.md

534 lines
14 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.

# 数据聚合与存储设计文档
## 一、设计原则
### 1.1 存储分层策略
```
热数据7天内 ←→ Loki + MySQL高频查询
温数据7-30天 ←→ Loki低频查询
冷数据30天+ ←→ 对象存储归档(极少查询)
```
### 1.2 存储选型
| 数据类型 | 存储 | 保留时长 | 说明 |
|----------|------|----------|------|
| 原始错误事件 | Loki | 7-30 天 | 堆栈、上下文、面包屑 |
| 原始性能事件 | Loki | 7 天 | 性能明细数据 |
| 原始行为事件 | Loki | 3 天 | PV、点击等量大 |
| 错误聚合统计 | MySQL | 永久 | 按指纹 + 时间聚合 |
| 性能指标统计 | MySQL | 90 天 | 按指标 + 时间聚合 |
| 项目配置 | MySQL | 永久 | 项目、用户、告警规则 |
| 告警记录 | MySQL | 90 天 | 告警历史 |
### 1.3 设计目标
- **Loki 存储成本**100万错误事件 ≈ 5GB/月
- **MySQL 存储成本**:聚合数据 ≈ 100MB/月/项目
- **查询性能**:聚合查询 < 100ms原始日志查询 < 2s
---
## 二、Loki 存储设计
### 2.1 Label 设计
Loki Label 是查询索引设计原则**低基数高区分度**。
| Label | 说明 | 基数 | 示例 |
|-------|------|------|------|
| `project_id` | 项目 ID | 项目数 | 1001 |
| `type` | 事件类型 | 极低 | error / performance / network / behavior |
| `level` | 日志级别 | 极低 | fatal / error / warning / info |
| `platform` | 平台 | | javascript / node / python |
| `environment` | 环境 | | production / staging / development |
| `fingerprint` | 错误指纹 | | abc123def |
**反模式(不要用做 Label**
- `event_id`基数太高每个事件都不同
- `message`基数太高且是文本
- `url`基数太高
- `user_id`基数太高
这些应该放在日志内容里 grep 查询
### 2.2 日志格式JSON
每条日志是一行 JSON方便 Loki `json` 解析器提取字段
#### 错误事件格式
```json
{
"event_id": "abc123...",
"type": "error",
"level": "error",
"timestamp": 1704067200000,
"project_id": "1001",
"platform": "javascript",
"environment": "production",
"release": "1.0.0",
"fingerprint": "a1b2c3d4e5f6",
"message": "Cannot read property 'foo' of undefined",
"exception": {
"type": "TypeError",
"value": "Cannot read property 'foo' of undefined",
"stacktrace": {
"frames": [
{
"filename": "https://example.com/app.js",
"function": "onClick",
"lineno": 123,
"colno": 45,
"in_app": true
}
]
}
},
"user": {
"id": "123",
"username": "testuser"
},
"tags": {
"page": "/home",
"browser": "Chrome 120"
},
"extra": {},
"breadcrumbs": [],
"request": {
"url": "https://example.com/page",
"headers": {
"user_agent": "Mozilla/5.0..."
}
}
}
```
#### 性能事件格式
```json
{
"type": "performance",
"level": "info",
"timestamp": 1704067200000,
"project_id": "1001",
"metric": "lcp",
"value": 2500,
"unit": "ms",
"rating": "good",
"tags": {
"page_url": "https://example.com/page",
"route": "/home",
"browser": "Chrome 120",
"os": "Mac OS X"
}
}
```
#### 网络事件格式
```json
{
"type": "network",
"level": "info",
"timestamp": 1704067200000,
"project_id": "1001",
"sub_type": "fetch",
"method": "GET",
"url": "/api/users",
"status_code": 200,
"duration": 123,
"success": true,
"tags": {
"route": "/home"
}
}
```
### 2.3 Loki 查询示例
**查询某项目的错误总数1小时内**
```logql
count_over_time(
{project_id="1001", type="error"}[1h]
)
```
**查询某错误指纹的出现次数**
```logql
count_over_time(
{project_id="1001", fingerprint="a1b2c3d4"}[24h]
)
```
**查询某页面的 LCP P95**
```logql
quantile_over_time(
0.95,
{project_id="1001", type="performance"}
| json value=value
| metric="lcp"
| unwrap value
[5m]
)
```
---
## 三、MySQL 聚合表设计
### 3.1 错误聚合表
#### `error_stats_hourly` - 错误小时统计表
按错误指纹 + 项目 + 小时聚合用于趋势图
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT PK | 主键 |
| `project_id` | VARCHAR(64) | 项目 ID |
| `fingerprint` | VARCHAR(64) | 错误指纹 |
| `stat_hour` | DATETIME | 统计小时整点 |
| `error_type` | VARCHAR(128) | 错误类型TypeError |
| `error_message` | VARCHAR(512) | 错误消息截断 |
| `count` | INT | 发生次数 |
| `affected_users` | INT | 影响用户数估算 |
| `first_seen` | DATETIME | 首次出现时间 |
| `last_seen` | DATETIME | 最后出现时间 |
| `created_at` | DATETIME | 创建时间 |
| `updated_at` | DATETIME | 更新时间 |
**索引**
- `(project_id, stat_hour)` - 按项目+时间查询
- `(project_id, fingerprint, stat_hour)` - 按指纹+时间查询
- 唯一键`(project_id, fingerprint, stat_hour)`
#### `error_issues` - 错误 Issue 表
按错误指纹聚合用于错误列表管理
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT PK | 主键 |
| `project_id` | VARCHAR(64) | 项目 ID |
| `fingerprint` | VARCHAR(64) UNIQUE | 错误指纹 |
| `error_type` | VARCHAR(128) | 错误类型 |
| `error_message` | TEXT | 错误消息 |
| `stack_trace` | TEXT | 堆栈摘要前几帧 |
| `level` | VARCHAR(32) | 级别 |
| `platform` | VARCHAR(32) | 平台 |
| `total_count` | BIGINT | 总次数 |
| `today_count` | INT | 今日次数 |
| `yesterday_count` | INT | 昨日次数 |
| `affected_users` | INT | 影响用户数 |
| `status` | VARCHAR(32) | 状态active / resolved / ignored |
| `assignee` | VARCHAR(64) | 处理人 |
| `first_seen` | DATETIME | 首次出现 |
| `last_seen` | DATETIME | 最后出现 |
| `created_at` | DATETIME | 创建时间 |
| `updated_at` | DATETIME | 更新时间 |
**状态流转**
```
active ──→ resolved ──→ active再次出现时复活
└──→ ignored
```
### 3.2 性能统计表
#### `performance_stats_hourly` - 性能小时统计表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT PK | 主键 |
| `project_id` | VARCHAR(64) | 项目 ID |
| `metric` | VARCHAR(32) | 指标名lcp / fcp / cls / fid |
| `stat_hour` | DATETIME | 统计小时 |
| `page_url` | VARCHAR(256) | 页面 URL可选=全部) |
| `sample_count` | INT | 样本数 |
| `p50` | DOUBLE | 中位数 |
| `p75` | DOUBLE | 75 分位 |
| `p90` | DOUBLE | 90 分位 |
| `p95` | DOUBLE | 95 分位 |
| `p99` | DOUBLE | 99 分位 |
| `avg` | DOUBLE | 平均值 |
| `good_rate` | DOUBLE | Good 比例0-1 |
| `poor_rate` | DOUBLE | Poor 比例0-1 |
| `created_at` | DATETIME | 创建时间 |
| `updated_at` | DATETIME | 更新时间 |
**索引**
- `(project_id, metric, stat_hour)` - 主查询索引
- 唯一键`(project_id, metric, stat_hour, page_url)`
#### `performance_stats_daily` - 性能日统计表
同上按天聚合用于长期趋势
### 3.3 网络请求统计表
#### `network_stats_hourly` - 网络小时统计表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT PK | 主键 |
| `project_id` | VARCHAR(64) | 项目 ID |
| `stat_hour` | DATETIME | 统计小时 |
| `method` | VARCHAR(16) | 请求方法 |
| `url_pattern` | VARCHAR(512) | URL 模式归一化后 |
| `total_count` | INT | 总请求数 |
| `error_count` | INT | 错误数4xx + 5xx |
| `error_rate` | DOUBLE | 错误率 |
| `avg_duration` | DOUBLE | 平均耗时(ms) |
| `p50_duration` | DOUBLE | P50 耗时 |
| `p95_duration` | DOUBLE | P95 耗时 |
| `created_at` | DATETIME | 创建时间 |
**URL 归一化**
- `/api/users/123` `/api/users/:id`
- `/static/app.abc123.js` `/static/app.[hash].js`
### 3.4 环境维度表
用于减少 Loki 中环境信息的冗余配合 SDK 的字典编码和会话级共享使用
#### `env_dimensions` - 环境维度表
每条唯一的环境组合只存 1 日志中通过 `env_id` 关联
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT PK | 维度 ID |
| `project_id` | VARCHAR(64) | 项目 ID |
| `browser_name` | VARCHAR(32) | 浏览器名称 |
| `browser_version` | VARCHAR(32) | 浏览器主版本 |
| `os_name` | VARCHAR(32) | 操作系统 |
| `os_version` | VARCHAR(32) | OS 主版本 |
| `device_family` | VARCHAR(64) | 设备系列 |
| `device_model` | VARCHAR(64) | 设备型号 |
| `screen_resolution` | VARCHAR(32) | 屏幕分辨率 |
| `language` | VARCHAR(16) | 浏览器语言 |
| `hash` | VARCHAR(64) UNIQUE | 所有维度的哈希值用于快速查找 |
| `first_seen` | DATETIME | 首次出现 |
| `last_seen` | DATETIME | 最后出现 |
| `count` | BIGINT | 出现次数用于热度排序 |
**唯一键**`(project_id, hash)`
**为什么用维度表?**
- Loki JSON 日志里只存 `env_id`1 个数字不存完整的浏览器/OS/设备信息
- 每条日志节省 100-200 字节百万级事件节省几十 GB
- 查询时 JOIN 维度表或者直接用 Grafana 的变量查询
**服务端处理流程**
```
事件到达 → 计算环境维度的 hash →
├─ 已存在 → 取 env_id更新 last_seen + count
└─ 不存在 → 插入新记录,返回新 env_id
→ 日志写入 Loki只带 env_id
```
#### `page_dimensions` - 页面维度表
同理页面 URL 也可以做维度化
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT PK | 页面 ID |
| `project_id` | VARCHAR(64) | 项目 ID |
| `url_pattern` | VARCHAR(512) | 归一化后的 URL 模式 |
| `path` | VARCHAR(512) | 路由路径 |
| `title` | VARCHAR(256) | 页面标题 |
| `hash` | VARCHAR(64) UNIQUE | 哈希 |
| `count` | BIGINT | 访问次数 |
#### `api_dimensions` - 接口维度表
网络请求的 URLmethod 等也可以维度化日志里只存 `api_id`
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT PK | 接口 ID |
| `project_id` | VARCHAR(64) | 项目 ID |
| `method` | VARCHAR(16) | 请求方法GET/POST/... |
| `url_pattern` | VARCHAR(512) | 归一化后的 URL 模式 |
| `domain` | VARCHAR(128) | 域名 |
| `hash` | VARCHAR(64) UNIQUE | method + url_pattern 的哈希 |
| `total_count` | BIGINT | 总请求数 |
| `error_count` | BIGINT | 错误数 |
| `avg_duration` | DOUBLE | 平均耗时 |
| `last_seen` | DATETIME | 最后出现时间 |
**为什么要做接口维度化?**
- 日志里只存 `api_id`8 字节不用存完整 URL
- URL 归一化后同一路由的不同参数只会产生 1 条维度记录
- 按接口统计错误率耗时等指标时直接 JOIN 维度表即可
### 3.5 项目配置表
#### `projects` - 项目表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | VARCHAR(64) PK | 内部 IDproj_xxx |
| `project_id` | VARCHAR(32) UNIQUE | Sentry 项目 ID纯数字 |
| `public_key` | VARCHAR(64) | 公钥默认=内部 ID |
| `name` | VARCHAR(128) | 项目名称 |
| `platform` | VARCHAR(32) | 平台 |
| `environment` | VARCHAR(32) | 默认环境 |
| `description` | TEXT | 描述 |
| `status` | VARCHAR(32) | 状态active / disabled |
| `rate_limit` | INT | 速率限制事件/ |
| `sample_rate` | DOUBLE | 采样率0-1 |
| `created_at` | DATETIME | 创建时间 |
| `updated_at` | DATETIME | 更新时间 |
#### `alert_rules` - 告警规则表
见告警引擎设计文档
---
## 四、聚合任务设计
### 4.1 聚合方式
| 方式 | 实时性 | 复杂度 | 适用场景 |
|------|--------|--------|----------|
| **实时增量聚合** | 秒级 | | 错误计数Issue 更新 |
| **定时批量聚合** | 分钟级 | | 性能分位数小时统计 |
| **离线重算** | 天级 | | 数据修正历史回刷 |
### 4.2 实时增量聚合(错误计数)
每次事件处理时直接更新 MySQL
```sql
INSERT INTO error_issues
(project_id, fingerprint, error_type, error_message,
total_count, today_count, last_seen, first_seen, status)
VALUES
(?, ?, ?, ?, 1, 1, NOW(), NOW(), 'active')
ON DUPLICATE KEY UPDATE
total_count = total_count + 1,
today_count = today_count + 1,
last_seen = NOW(),
status = CASE WHEN status = 'resolved' THEN 'active' ELSE status END;
```
**优点**实时性好数据立刻可见
**缺点**高并发下 MySQL 压力大
**优化**
- 内存中先做 1 秒级别的微批聚合再批量写 MySQL
- 使用 Redis 做计数缓冲定期刷入 MySQL
### 4.3 定时批量聚合(性能统计)
使用 Cron 定时任务 5 分钟从 Loki 拉取数据聚合
```
每 5 分钟执行:
1. 从 Loki 查询过去 5 分钟的性能事件
2. 按 project + metric + 页面 分组
3. 计算 P50/P90/P95/avg 等指标
4. 写入 performance_stats_hourly 表
```
**为什么用定时任务而不是实时?**
- 性能指标不需要秒级实时
- 分位数计算需要一定数据量才准确
- 定时批量更省资源
### 4.4 数据过期与归档
#### Loki 数据过期
通过 Loki `retention` 配置自动删除
```yaml
limits_config:
retention_period: 168h # 7 天
```
#### MySQL 数据过期
- 小时统计表保留 30
- 日统计表保留 1
- 错误 Issue 永久保留只存聚合数据量很小
定时任务每天凌晨清理过期数据
---
## 五、数据迁移与兼容
### 5.1 当前状态
目前项目使用 JSON 文件存储项目配置Loki 存储原始日志没有 MySQL 聚合层
### 5.2 演进路径
**阶段 1JSON → SQLite单机版**
- 零依赖开箱即用
- 适合个人项目小团队
- 单节点足够
**阶段 2SQLite → MySQL生产版**
- 支持并发
- 性能更好
- 适合多项目中大型团队
**阶段 3增加 ClickHouse大规模**
- 亿级数据量
- 复杂分析查询
- 一般不需要
---
## 六、Grafana 数据源配置
### 6.1 Loki 数据源
- URL: `http://loki:3100`
- 开启 JSON 解析
- 配置 Derived fields从日志跳转到追踪
### 6.2 MySQL 数据源
- 用于展示聚合数据性能趋势错误趋势
- Loki 查询更稳定更快
---
## 七、数据安全
### 7.1 数据脱敏
- 入库前脱敏SDK + 服务端双重脱敏
- 查询时脱敏敏感字段查询结果自动打码
- 导出时脱敏导出数据默认脱敏
### 7.2 数据隔离
- 项目间完全隔离通过 project_id label
- 查询时强制带 project_id 条件
- 管理后台有项目权限控制
### 7.3 数据备份
- MySQL每日全量备份 + binlog 增量备份
- Loki定期快照到对象存储
- 配置文件Git 版本管理