feat(sentry): 集成 Sentry 自建崩溃收集与错误追踪系统
Backend (infrastructure/sentry): - 添加 Sentry 自建部署 Docker Compose 配置 - 包含 PostgreSQL, Redis, Kafka, ClickHouse, Snuba 等组件 - 添加 Relay (事件网关) 和 Symbolicator (符号化服务) 配置 - 添加部署脚本 deploy.sh 和配置文件 - 更新 infrastructure README 文档 Frontend (mobile-app): - 添加 sentry_flutter SDK 依赖 - 创建 SentryService 封装类,统一管理崩溃收集 - 创建 SentryConfig 配置类,支持开发/生产环境配置 - 创建 SentryNavigationObserver 自动追踪页面导航 - 创建 SentryDioInterceptor 自动追踪 HTTP 请求 - 在 bootstrap.dart 中集成 Sentry 初始化 - 支持 Flutter 错误和异步错误捕获 - 自动过滤敏感信息 (密码、助记词、私钥等) - 在登录/登出/切换账号时同步 Sentry 用户信息 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cc06638e0e
commit
80b74e9877
|
|
@ -193,7 +193,9 @@
|
|||
"Bash(git tag -a v1.0.0-beta1 -m \"$(cat <<''EOF''\nv1.0.0-beta1: 用户首次测试通过\n\n主要修复:\n- fix(reward): 修复 accountSequence 转 userId 时字母前缀导致的 BigInt 转换失败\n- fix(authorization): 修复下级团队认种数重复减去自己认种数的 BUG\n- fix(frontend): 修正权益金额显示与后端实际配置一致\n\n功能完善:\n- 社区权益激活正常\n- 市团队/省团队/市区域/省区域权益考核显示\n- 我的伞下功能 - 展示下级用户树形结构\n\n此版本可作为回滚基准点\nEOF\n)\")",
|
||||
"Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\frontend\\mobile-app\\assets\\images\\splash_frames\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianfrontendmobile-appassetsimagessplash_frames\" 2>nul || echo \"目录不存在 \")",
|
||||
"Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\frontend\\mobile-app\\lib\\features\"\" | grep -E \"^d \")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(telemetry): 将userId改为userSerialNum字符串格式并完善遥测追踪\n\nBackend (presence-service):\n- 将EventLog.userId从BigInt改为String类型,存储userSerialNum(如D25121400005)\n- 更新Prisma schema,userId字段改为VarChar(20)并添加索引\n- 更新心跳相关命令和事件,统一使用userSerialNum字符串\n- 添加数据库迁移文件\n- 更新相关单元测试和集成测试\n\nFrontend (mobile-app):\n- TelemetryEvent新增toServerJson()方法,格式化为后端API期望的格式\n- AccountService登录/恢复时设置TelemetryService的userId\n- MultiAccountService切换账号时同步更新TelemetryService的userId\n- 退出登录时清除TelemetryService的userId\n- AuthProvider初始化时设置userId\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")"
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(telemetry): 将userId改为userSerialNum字符串格式并完善遥测追踪\n\nBackend (presence-service):\n- 将EventLog.userId从BigInt改为String类型,存储userSerialNum(如D25121400005)\n- 更新Prisma schema,userId字段改为VarChar(20)并添加索引\n- 更新心跳相关命令和事件,统一使用userSerialNum字符串\n- 添加数据库迁移文件\n- 更新相关单元测试和集成测试\n\nFrontend (mobile-app):\n- TelemetryEvent新增toServerJson()方法,格式化为后端API期望的格式\n- AccountService登录/恢复时设置TelemetryService的userId\n- MultiAccountService切换账号时同步更新TelemetryService的userId\n- 退出登录时清除TelemetryService的userId\n- AuthProvider初始化时设置userId\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(sentry): 集成 Sentry 自建崩溃收集与错误追踪系统\n\nBackend (infrastructure/sentry):\n- 添加 Sentry 自建部署 Docker Compose 配置\n- 包含 PostgreSQL, Redis, Kafka, ClickHouse, Snuba 等组件\n- 添加 Relay (事件网关) 和 Symbolicator (符号化服务) 配置\n- 添加部署脚本 deploy.sh 和配置文件\n- 更新 infrastructure README 文档\n\nFrontend (mobile-app):\n- 添加 sentry_flutter SDK 依赖\n- 创建 SentryService 封装类,统一管理崩溃收集\n- 创建 SentryConfig 配置类,支持开发/生产环境配置\n- 创建 SentryNavigationObserver 自动追踪页面导航\n- 创建 SentryDioInterceptor 自动追踪 HTTP 请求\n- 在 bootstrap.dart 中集成 Sentry 初始化\n- 支持 Flutter 错误和异步错误捕获\n- 自动过滤敏感信息 (密码、助记词、私钥等)\n- 在登录/登出/切换账号时同步 Sentry 用户信息\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ cd infrastructure
|
|||
| Grafana | http://localhost:3030 | 统一仪表盘 |
|
||||
| Prometheus | http://localhost:9090 | 指标查询 |
|
||||
| Loki | http://localhost:3100 | 日志 API |
|
||||
| **Sentry** | http://localhost:9000 | **崩溃收集 & 错误追踪** |
|
||||
|
||||
## 组件说明
|
||||
|
||||
|
|
@ -164,6 +165,35 @@ sdk.start();
|
|||
- Loki (日志)
|
||||
- Jaeger (追踪)
|
||||
|
||||
### Sentry - 崩溃收集与错误追踪 (独立部署)
|
||||
|
||||
**功能:**
|
||||
- 崩溃收集 (Flutter + Android/iOS 原生层)
|
||||
- 错误追踪与堆栈符号化
|
||||
- 设备兼容性分析
|
||||
- 性能监控
|
||||
- Session Replay
|
||||
|
||||
**部署方式:**
|
||||
|
||||
Sentry 是独立部署的服务,位于 `sentry/` 目录:
|
||||
|
||||
```bash
|
||||
cd sentry
|
||||
|
||||
# 首次安装 (创建管理员账号)
|
||||
./deploy.sh init
|
||||
|
||||
# 启动服务
|
||||
./deploy.sh up
|
||||
```
|
||||
|
||||
**系统要求:**
|
||||
- 最低 4GB 内存,推荐 8GB+
|
||||
- 约 20GB 磁盘空间
|
||||
|
||||
详细文档请参考: [sentry/README.md](sentry/README.md)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
|
|
@ -189,13 +219,19 @@ infrastructure/
|
|||
│ └── rules/
|
||||
│ └── rwa-alerts.yml # 告警规则
|
||||
│
|
||||
└── grafana/
|
||||
└── provisioning/
|
||||
├── datasources/
|
||||
│ └── datasources.yml # 数据源配置
|
||||
└── dashboards/
|
||||
├── dashboards.yml # 仪表盘配置
|
||||
└── rwa-services-overview.json
|
||||
├── grafana/
|
||||
│ └── provisioning/
|
||||
│ ├── datasources/
|
||||
│ │ └── datasources.yml # 数据源配置
|
||||
│ └── dashboards/
|
||||
│ ├── dashboards.yml # 仪表盘配置
|
||||
│ └── rwa-services-overview.json
|
||||
│
|
||||
└── sentry/ # 崩溃收集 (独立部署)
|
||||
├── docker-compose.yml # Sentry 编排
|
||||
├── deploy.sh # 部署脚本
|
||||
├── sentry.conf.py # Sentry 配置
|
||||
└── README.md # 详细文档
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
|
@ -227,6 +263,7 @@ infrastructure/
|
|||
| 详细指标 | - | 添加 Prometheus 中间件 |
|
||||
| 链路追踪 | - | 添加 OpenTelemetry SDK |
|
||||
| 动态配置 | - | 集成 Consul KV |
|
||||
| **崩溃收集** | - | **集成 sentry_flutter SDK** |
|
||||
|
||||
## 扩展配置
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
# =============================================================================
|
||||
# Sentry Self-Hosted 环境变量配置
|
||||
# =============================================================================
|
||||
# 使用方法: 复制为 .env 并修改以下配置
|
||||
# cp .env.example .env
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 必须配置 - 安全相关
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Sentry 密钥 (至少 50 个随机字符)
|
||||
# 生成方法: openssl rand -hex 32
|
||||
SENTRY_SECRET_KEY=please_change_this_to_a_random_string_at_least_50_chars
|
||||
|
||||
# PostgreSQL 数据库密码
|
||||
SENTRY_DB_PASSWORD=sentry_secret_password
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 端口配置
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Sentry Web UI 端口
|
||||
SENTRY_PORT=9000
|
||||
|
||||
# Relay 端口 (SDK 数据接收)
|
||||
SENTRY_RELAY_PORT=3000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 邮件配置 (可选,用于告警通知)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# SMTP 服务器
|
||||
SENTRY_EMAIL_HOST=smtp.example.com
|
||||
SENTRY_EMAIL_PORT=587
|
||||
SENTRY_EMAIL_USER=
|
||||
SENTRY_EMAIL_PASSWORD=
|
||||
SENTRY_EMAIL_USE_TLS=true
|
||||
|
||||
# 发件人地址
|
||||
SENTRY_SERVER_EMAIL=sentry@example.com
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 系统配置
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 系统 URL (用于邮件链接等)
|
||||
SENTRY_SYSTEM_URL=http://localhost:9000
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
# Sentry Self-Hosted - 崩溃收集与错误追踪
|
||||
|
||||
100% 自主可控的崩溃收集与错误追踪系统。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **崩溃收集**: 自动捕获 Flutter + Android/iOS 原生层崩溃
|
||||
- **符号化**: 自动解析混淆后的堆栈,定位到源代码
|
||||
- **设备信息**: 收集设备型号、系统版本、内存等兼容性信息
|
||||
- **性能监控**: 页面加载、API 响应时间等性能指标
|
||||
- **Session Replay**: 重放用户操作轨迹(可选)
|
||||
- **告警通知**: 崩溃告警推送到邮件/钉钉等
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose v2+
|
||||
- **最低 4GB 内存,推荐 8GB+**
|
||||
- 约 20GB 磁盘空间
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 首次安装
|
||||
|
||||
```bash
|
||||
cd backend/infrastructure/sentry
|
||||
|
||||
# 初始化 (会创建管理员账号)
|
||||
./deploy.sh init
|
||||
```
|
||||
|
||||
按提示输入管理员邮箱和密码。
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
./deploy.sh up
|
||||
```
|
||||
|
||||
### 3. 访问 Web UI
|
||||
|
||||
打开浏览器访问: http://localhost:9000
|
||||
|
||||
使用初始化时创建的管理员账号登录。
|
||||
|
||||
### 4. 创建项目
|
||||
|
||||
1. 登录后点击 "Create Project"
|
||||
2. 选择平台: **Flutter**
|
||||
3. 记录生成的 **DSN** (Data Source Name)
|
||||
|
||||
DSN 格式示例:
|
||||
```
|
||||
http://your_public_key@localhost:9000/project_id
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
sentry/
|
||||
├── docker-compose.yml # Docker 编排
|
||||
├── deploy.sh # 部署脚本
|
||||
├── .env.example # 环境变量模板
|
||||
├── sentry.conf.py # Sentry 配置
|
||||
├── README.md # 本文档
|
||||
│
|
||||
├── clickhouse/
|
||||
│ └── config.xml # ClickHouse 配置
|
||||
│
|
||||
├── relay/
|
||||
│ ├── config.yml # Relay 配置
|
||||
│ └── credentials.json # Relay 凭证
|
||||
│
|
||||
└── symbolicator/
|
||||
└── config.yml # Symbolicator 配置
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 启动
|
||||
./deploy.sh up
|
||||
|
||||
# 停止
|
||||
./deploy.sh down
|
||||
|
||||
# 查看状态
|
||||
./deploy.sh status
|
||||
|
||||
# 查看日志
|
||||
./deploy.sh logs
|
||||
./deploy.sh logs sentry-web
|
||||
|
||||
# 升级
|
||||
./deploy.sh upgrade
|
||||
```
|
||||
|
||||
## 架构说明
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Flutter App │
|
||||
│ sentry_flutter │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Sentry Relay │
|
||||
│ (事件接收网关 :3000) │
|
||||
└────────────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Kafka │ │ Sentry Web │ │ Symbolicator │
|
||||
│ (事件队列) │ │ (Web UI :9000) │ │ (符号化服务) │
|
||||
└────────┬────────┘ └────────┬────────┘ └─────────────────┘
|
||||
│ │
|
||||
│ ┌────────┴────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ ClickHouse │ │ PostgreSQL │
|
||||
│ (事件存储) │ │ (元数据) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## 生产环境配置
|
||||
|
||||
### 1. 修改密钥
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# 生成强密钥
|
||||
SENTRY_SECRET_KEY=$(openssl rand -hex 32)
|
||||
SENTRY_DB_PASSWORD=$(openssl rand -hex 16)
|
||||
```
|
||||
|
||||
### 2. 配置域名
|
||||
|
||||
如果需要公网访问,配置反向代理:
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/conf.d/sentry.conf
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sentry.your-domain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:9000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Relay 端口 (SDK 上报)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sentry-relay.your-domain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 配置邮件告警
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```bash
|
||||
SENTRY_EMAIL_HOST=smtp.example.com
|
||||
SENTRY_EMAIL_PORT=587
|
||||
SENTRY_EMAIL_USER=your-email@example.com
|
||||
SENTRY_EMAIL_PASSWORD=your-password
|
||||
SENTRY_EMAIL_USE_TLS=true
|
||||
SENTRY_SERVER_EMAIL=sentry@your-domain.com
|
||||
```
|
||||
|
||||
### 4. 数据保留策略
|
||||
|
||||
编辑 `sentry.conf.py`:
|
||||
|
||||
```python
|
||||
# 事件保留天数 (默认 90 天)
|
||||
SENTRY_OPTIONS["system.event-retention-days"] = 30
|
||||
```
|
||||
|
||||
## Flutter 端集成
|
||||
|
||||
详见 `frontend/mobile-app` 目录下的集成代码。
|
||||
|
||||
简要步骤:
|
||||
|
||||
1. 添加依赖:
|
||||
```yaml
|
||||
dependencies:
|
||||
sentry_flutter: ^8.0.0
|
||||
```
|
||||
|
||||
2. 初始化:
|
||||
```dart
|
||||
await SentryFlutter.init(
|
||||
(options) {
|
||||
options.dsn = 'http://your_key@your-server:9000/project_id';
|
||||
},
|
||||
appRunner: () => runApp(MyApp()),
|
||||
);
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 服务无法启动
|
||||
|
||||
检查内存是否足够:
|
||||
```bash
|
||||
free -h
|
||||
docker stats
|
||||
```
|
||||
|
||||
### 事件未上报
|
||||
|
||||
1. 检查 DSN 是否正确
|
||||
2. 检查网络连通性
|
||||
3. 查看 Relay 日志: `./deploy.sh logs sentry-relay`
|
||||
|
||||
### 符号化失败
|
||||
|
||||
1. 确保上传了符号文件 (Android: mapping.txt, iOS: dSYM)
|
||||
2. 检查 Symbolicator 日志: `./deploy.sh logs sentry-symbolicator`
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Sentry 官方文档](https://docs.sentry.io/)
|
||||
- [Self-Hosted 安装指南](https://develop.sentry.dev/self-hosted/)
|
||||
- [sentry_flutter SDK](https://pub.dev/packages/sentry_flutter)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0"?>
|
||||
<!-- ClickHouse Sentry 配置 -->
|
||||
<clickhouse>
|
||||
<!-- 日志级别 -->
|
||||
<logger>
|
||||
<level>warning</level>
|
||||
<console>true</console>
|
||||
</logger>
|
||||
|
||||
<!-- 内存限制 -->
|
||||
<max_server_memory_usage_to_ram_ratio>0.8</max_server_memory_usage_to_ram_ratio>
|
||||
|
||||
<!-- 查询限制 -->
|
||||
<max_concurrent_queries>100</max_concurrent_queries>
|
||||
|
||||
<!-- 连接数限制 -->
|
||||
<max_connections>4096</max_connections>
|
||||
|
||||
<!-- 压缩配置 -->
|
||||
<compression>
|
||||
<case>
|
||||
<min_part_size>10000000000</min_part_size>
|
||||
<min_part_size_ratio>0.01</min_part_size_ratio>
|
||||
<method>lz4</method>
|
||||
</case>
|
||||
</compression>
|
||||
</clickhouse>
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Sentry Self-Hosted 部署脚本
|
||||
# =============================================================================
|
||||
# 使用方法:
|
||||
# ./deploy.sh init # 首次初始化 (创建管理员账号)
|
||||
# ./deploy.sh up # 启动服务
|
||||
# ./deploy.sh down # 停止服务
|
||||
# ./deploy.sh logs # 查看日志
|
||||
# ./deploy.sh status # 查看状态
|
||||
# ./deploy.sh upgrade # 升级 Sentry
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 检查 .env 文件
|
||||
check_env() {
|
||||
if [ ! -f .env ]; then
|
||||
echo -e "${YELLOW}未找到 .env 文件,正在从模板创建...${NC}"
|
||||
cp .env.example .env
|
||||
|
||||
# 生成随机密钥
|
||||
RANDOM_KEY=$(openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | base64 | tr -d '\n' | head -c 64)
|
||||
sed -i "s/please_change_this_to_a_random_string_at_least_50_chars/$RANDOM_KEY/" .env
|
||||
|
||||
echo -e "${GREEN}.env 文件已创建,请检查并修改配置后重新运行${NC}"
|
||||
echo -e "${YELLOW}特别注意修改: SENTRY_SECRET_KEY 和 SENTRY_DB_PASSWORD${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 初始化 Sentry (首次运行)
|
||||
init() {
|
||||
echo -e "${GREEN}=== 初始化 Sentry ===${NC}"
|
||||
check_env
|
||||
|
||||
echo -e "${YELLOW}1. 启动依赖服务...${NC}"
|
||||
docker compose up -d sentry-redis sentry-postgres sentry-kafka sentry-zookeeper sentry-clickhouse
|
||||
|
||||
echo -e "${YELLOW}等待服务就绪...${NC}"
|
||||
sleep 30
|
||||
|
||||
echo -e "${YELLOW}2. 运行数据库迁移...${NC}"
|
||||
docker compose run --rm sentry-web upgrade --noinput
|
||||
|
||||
echo -e "${YELLOW}3. 创建管理员账号...${NC}"
|
||||
echo -e "${GREEN}请按提示输入管理员邮箱和密码${NC}"
|
||||
docker compose run --rm sentry-web createuser --superuser
|
||||
|
||||
echo -e "${GREEN}=== 初始化完成 ===${NC}"
|
||||
echo -e "${YELLOW}运行 './deploy.sh up' 启动所有服务${NC}"
|
||||
}
|
||||
|
||||
# 启动服务
|
||||
up() {
|
||||
echo -e "${GREEN}=== 启动 Sentry ===${NC}"
|
||||
check_env
|
||||
docker compose up -d
|
||||
echo -e "${GREEN}Sentry 已启动${NC}"
|
||||
echo -e "Web UI: http://localhost:${SENTRY_PORT:-9000}"
|
||||
echo -e "Relay: http://localhost:${SENTRY_RELAY_PORT:-3000}"
|
||||
}
|
||||
|
||||
# 停止服务
|
||||
down() {
|
||||
echo -e "${YELLOW}=== 停止 Sentry ===${NC}"
|
||||
docker compose down
|
||||
echo -e "${GREEN}Sentry 已停止${NC}"
|
||||
}
|
||||
|
||||
# 查看日志
|
||||
logs() {
|
||||
SERVICE=${1:-}
|
||||
if [ -z "$SERVICE" ]; then
|
||||
docker compose logs -f --tail=100
|
||||
else
|
||||
docker compose logs -f --tail=100 "$SERVICE"
|
||||
fi
|
||||
}
|
||||
|
||||
# 查看状态
|
||||
status() {
|
||||
echo -e "${GREEN}=== Sentry 服务状态 ===${NC}"
|
||||
docker compose ps
|
||||
}
|
||||
|
||||
# 升级
|
||||
upgrade() {
|
||||
echo -e "${YELLOW}=== 升级 Sentry ===${NC}"
|
||||
|
||||
echo "1. 停止服务..."
|
||||
docker compose down
|
||||
|
||||
echo "2. 拉取最新镜像..."
|
||||
docker compose pull
|
||||
|
||||
echo "3. 运行数据库迁移..."
|
||||
docker compose run --rm sentry-web upgrade --noinput
|
||||
|
||||
echo "4. 启动服务..."
|
||||
docker compose up -d
|
||||
|
||||
echo -e "${GREEN}=== 升级完成 ===${NC}"
|
||||
}
|
||||
|
||||
# 生成 Relay 凭证
|
||||
generate_relay_credentials() {
|
||||
echo -e "${YELLOW}=== 生成 Relay 凭证 ===${NC}"
|
||||
docker compose run --rm sentry-relay credentials generate --stdout > relay/credentials.json
|
||||
echo -e "${GREEN}凭证已生成到 relay/credentials.json${NC}"
|
||||
}
|
||||
|
||||
# 帮助信息
|
||||
help() {
|
||||
echo "Sentry Self-Hosted 部署脚本"
|
||||
echo ""
|
||||
echo "用法: ./deploy.sh <命令>"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " init 首次初始化 (创建管理员账号)"
|
||||
echo " up 启动所有服务"
|
||||
echo " down 停止所有服务"
|
||||
echo " logs [service] 查看日志 (可指定服务)"
|
||||
echo " status 查看服务状态"
|
||||
echo " upgrade 升级 Sentry"
|
||||
echo " generate-relay 生成 Relay 凭证"
|
||||
echo " help 显示此帮助信息"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " ./deploy.sh init # 首次安装"
|
||||
echo " ./deploy.sh up # 启动"
|
||||
echo " ./deploy.sh logs sentry-web # 查看 Web 服务日志"
|
||||
}
|
||||
|
||||
# 主入口
|
||||
case "${1:-help}" in
|
||||
init)
|
||||
init
|
||||
;;
|
||||
up)
|
||||
up
|
||||
;;
|
||||
down)
|
||||
down
|
||||
;;
|
||||
logs)
|
||||
logs "$2"
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
upgrade)
|
||||
upgrade
|
||||
;;
|
||||
generate-relay)
|
||||
generate_relay_credentials
|
||||
;;
|
||||
help|--help|-h)
|
||||
help
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}未知命令: $1${NC}"
|
||||
help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
# =============================================================================
|
||||
# Sentry Self-Hosted - 崩溃收集与错误追踪
|
||||
# =============================================================================
|
||||
#
|
||||
# 功能:
|
||||
# - 崩溃收集(Flutter + Android/iOS 原生层)
|
||||
# - 错误追踪与符号化
|
||||
# - 性能监控
|
||||
# - Session Replay
|
||||
# - 设备兼容性分析
|
||||
#
|
||||
# 使用方法:
|
||||
# ./deploy.sh up # 首次启动(会自动初始化)
|
||||
# ./deploy.sh down # 停止
|
||||
# ./deploy.sh logs # 查看日志
|
||||
#
|
||||
# 系统要求:
|
||||
# - Docker 20.10+
|
||||
# - Docker Compose v2+
|
||||
# - 最低 4GB 内存,推荐 8GB+
|
||||
# - 约 20GB 磁盘空间
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ===========================================================================
|
||||
# Redis - 缓存与消息队列
|
||||
# ===========================================================================
|
||||
sentry-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: sentry-redis
|
||||
volumes:
|
||||
- sentry_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
# ===========================================================================
|
||||
# PostgreSQL - 主数据库
|
||||
# ===========================================================================
|
||||
sentry-postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: sentry-postgres
|
||||
environment:
|
||||
POSTGRES_USER: sentry
|
||||
POSTGRES_PASSWORD: ${SENTRY_DB_PASSWORD:-sentry_secret_password}
|
||||
POSTGRES_DB: sentry
|
||||
volumes:
|
||||
- sentry_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U sentry"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
# ===========================================================================
|
||||
# Kafka + Zookeeper - 事件流处理
|
||||
# ===========================================================================
|
||||
sentry-zookeeper:
|
||||
image: confluentinc/cp-zookeeper:7.5.0
|
||||
container_name: sentry-zookeeper
|
||||
environment:
|
||||
ZOOKEEPER_CLIENT_PORT: 2181
|
||||
ZOOKEEPER_TICK_TIME: 2000
|
||||
volumes:
|
||||
- sentry_zookeeper_data:/var/lib/zookeeper/data
|
||||
- sentry_zookeeper_log:/var/lib/zookeeper/log
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "2181"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-kafka:
|
||||
image: confluentinc/cp-kafka:7.5.0
|
||||
container_name: sentry-kafka
|
||||
depends_on:
|
||||
sentry-zookeeper:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
KAFKA_BROKER_ID: 1
|
||||
KAFKA_ZOOKEEPER_CONNECT: sentry-zookeeper:2181
|
||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://sentry-kafka:9092
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
||||
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
||||
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
||||
volumes:
|
||||
- sentry_kafka_data:/var/lib/kafka/data
|
||||
healthcheck:
|
||||
test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
# ===========================================================================
|
||||
# ClickHouse - 事件存储(高性能分析)
|
||||
# ===========================================================================
|
||||
sentry-clickhouse:
|
||||
image: clickhouse/clickhouse-server:23.8-alpine
|
||||
container_name: sentry-clickhouse
|
||||
volumes:
|
||||
- sentry_clickhouse_data:/var/lib/clickhouse
|
||||
- ./clickhouse/config.xml:/etc/clickhouse-server/config.d/sentry.xml:ro
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8123/ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
# ===========================================================================
|
||||
# Snuba - 事件搜索与聚合
|
||||
# ===========================================================================
|
||||
sentry-snuba-api:
|
||||
image: getsentry/snuba:24.1.0
|
||||
container_name: sentry-snuba-api
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-clickhouse:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SNUBA_SETTINGS: docker
|
||||
CLICKHOUSE_HOST: sentry-clickhouse
|
||||
REDIS_HOST: sentry-redis
|
||||
KAFKA_BROKER_HOST: sentry-kafka
|
||||
command: api
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-snuba-consumer:
|
||||
image: getsentry/snuba:24.1.0
|
||||
container_name: sentry-snuba-consumer
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-clickhouse:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SNUBA_SETTINGS: docker
|
||||
CLICKHOUSE_HOST: sentry-clickhouse
|
||||
REDIS_HOST: sentry-redis
|
||||
KAFKA_BROKER_HOST: sentry-kafka
|
||||
command: consumer --storage errors --auto-offset-reset=latest --max-batch-time-ms 750
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-snuba-outcomes-consumer:
|
||||
image: getsentry/snuba:24.1.0
|
||||
container_name: sentry-snuba-outcomes-consumer
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-clickhouse:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SNUBA_SETTINGS: docker
|
||||
CLICKHOUSE_HOST: sentry-clickhouse
|
||||
REDIS_HOST: sentry-redis
|
||||
KAFKA_BROKER_HOST: sentry-kafka
|
||||
command: consumer --storage outcomes_raw --auto-offset-reset=earliest --max-batch-time-ms 750
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-snuba-sessions-consumer:
|
||||
image: getsentry/snuba:24.1.0
|
||||
container_name: sentry-snuba-sessions-consumer
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-clickhouse:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SNUBA_SETTINGS: docker
|
||||
CLICKHOUSE_HOST: sentry-clickhouse
|
||||
REDIS_HOST: sentry-redis
|
||||
KAFKA_BROKER_HOST: sentry-kafka
|
||||
command: consumer --storage sessions_raw --auto-offset-reset=latest --max-batch-time-ms 750
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-snuba-transactions-consumer:
|
||||
image: getsentry/snuba:24.1.0
|
||||
container_name: sentry-snuba-transactions-consumer
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-clickhouse:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SNUBA_SETTINGS: docker
|
||||
CLICKHOUSE_HOST: sentry-clickhouse
|
||||
REDIS_HOST: sentry-redis
|
||||
KAFKA_BROKER_HOST: sentry-kafka
|
||||
command: consumer --storage transactions --auto-offset-reset=latest --max-batch-time-ms 750
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-snuba-replacer:
|
||||
image: getsentry/snuba:24.1.0
|
||||
container_name: sentry-snuba-replacer
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-clickhouse:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SNUBA_SETTINGS: docker
|
||||
CLICKHOUSE_HOST: sentry-clickhouse
|
||||
REDIS_HOST: sentry-redis
|
||||
KAFKA_BROKER_HOST: sentry-kafka
|
||||
command: replacer --storage errors --auto-offset-reset=latest --max-batch-size 3
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
# ===========================================================================
|
||||
# Sentry Core Services
|
||||
# ===========================================================================
|
||||
sentry-web:
|
||||
image: getsentry/sentry:24.1.0
|
||||
container_name: sentry-web
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-postgres:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
sentry-snuba-api:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${SENTRY_PORT:-9000}:9000"
|
||||
environment:
|
||||
SENTRY_SECRET_KEY: ${SENTRY_SECRET_KEY:-please_change_this_to_a_random_string_at_least_50_chars}
|
||||
SENTRY_POSTGRES_HOST: sentry-postgres
|
||||
SENTRY_DB_USER: sentry
|
||||
SENTRY_DB_PASSWORD: ${SENTRY_DB_PASSWORD:-sentry_secret_password}
|
||||
SENTRY_REDIS_HOST: sentry-redis
|
||||
SNUBA: http://sentry-snuba-api:1218
|
||||
SENTRY_KAFKA_HOST: sentry-kafka
|
||||
# 邮件配置(可选)
|
||||
SENTRY_EMAIL_HOST: ${SENTRY_EMAIL_HOST:-}
|
||||
SENTRY_EMAIL_PORT: ${SENTRY_EMAIL_PORT:-587}
|
||||
SENTRY_EMAIL_USER: ${SENTRY_EMAIL_USER:-}
|
||||
SENTRY_EMAIL_PASSWORD: ${SENTRY_EMAIL_PASSWORD:-}
|
||||
SENTRY_EMAIL_USE_TLS: ${SENTRY_EMAIL_USE_TLS:-true}
|
||||
SENTRY_SERVER_EMAIL: ${SENTRY_SERVER_EMAIL:-sentry@localhost}
|
||||
# 系统配置
|
||||
SENTRY_SINGLE_ORGANIZATION: "true"
|
||||
volumes:
|
||||
- sentry_data:/data
|
||||
- ./sentry.conf.py:/etc/sentry/sentry.conf.py:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:9000/_health/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-worker:
|
||||
image: getsentry/sentry:24.1.0
|
||||
container_name: sentry-worker
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-postgres:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SENTRY_SECRET_KEY: ${SENTRY_SECRET_KEY:-please_change_this_to_a_random_string_at_least_50_chars}
|
||||
SENTRY_POSTGRES_HOST: sentry-postgres
|
||||
SENTRY_DB_USER: sentry
|
||||
SENTRY_DB_PASSWORD: ${SENTRY_DB_PASSWORD:-sentry_secret_password}
|
||||
SENTRY_REDIS_HOST: sentry-redis
|
||||
SNUBA: http://sentry-snuba-api:1218
|
||||
SENTRY_KAFKA_HOST: sentry-kafka
|
||||
command: run worker
|
||||
volumes:
|
||||
- sentry_data:/data
|
||||
- ./sentry.conf.py:/etc/sentry/sentry.conf.py:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-cron:
|
||||
image: getsentry/sentry:24.1.0
|
||||
container_name: sentry-cron
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SENTRY_SECRET_KEY: ${SENTRY_SECRET_KEY:-please_change_this_to_a_random_string_at_least_50_chars}
|
||||
SENTRY_POSTGRES_HOST: sentry-postgres
|
||||
SENTRY_DB_USER: sentry
|
||||
SENTRY_DB_PASSWORD: ${SENTRY_DB_PASSWORD:-sentry_secret_password}
|
||||
SENTRY_REDIS_HOST: sentry-redis
|
||||
SNUBA: http://sentry-snuba-api:1218
|
||||
SENTRY_KAFKA_HOST: sentry-kafka
|
||||
command: run cron
|
||||
volumes:
|
||||
- sentry_data:/data
|
||||
- ./sentry.conf.py:/etc/sentry/sentry.conf.py:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-ingest-consumer:
|
||||
image: getsentry/sentry:24.1.0
|
||||
container_name: sentry-ingest-consumer
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-postgres:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SENTRY_SECRET_KEY: ${SENTRY_SECRET_KEY:-please_change_this_to_a_random_string_at_least_50_chars}
|
||||
SENTRY_POSTGRES_HOST: sentry-postgres
|
||||
SENTRY_DB_USER: sentry
|
||||
SENTRY_DB_PASSWORD: ${SENTRY_DB_PASSWORD:-sentry_secret_password}
|
||||
SENTRY_REDIS_HOST: sentry-redis
|
||||
SNUBA: http://sentry-snuba-api:1218
|
||||
SENTRY_KAFKA_HOST: sentry-kafka
|
||||
command: run ingest-consumer --all-consumer-types
|
||||
volumes:
|
||||
- sentry_data:/data
|
||||
- ./sentry.conf.py:/etc/sentry/sentry.conf.py:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
sentry-post-process-forwarder:
|
||||
image: getsentry/sentry:24.1.0
|
||||
container_name: sentry-post-process-forwarder
|
||||
depends_on:
|
||||
sentry-redis:
|
||||
condition: service_healthy
|
||||
sentry-postgres:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SENTRY_SECRET_KEY: ${SENTRY_SECRET_KEY:-please_change_this_to_a_random_string_at_least_50_chars}
|
||||
SENTRY_POSTGRES_HOST: sentry-postgres
|
||||
SENTRY_DB_USER: sentry
|
||||
SENTRY_DB_PASSWORD: ${SENTRY_DB_PASSWORD:-sentry_secret_password}
|
||||
SENTRY_REDIS_HOST: sentry-redis
|
||||
SNUBA: http://sentry-snuba-api:1218
|
||||
SENTRY_KAFKA_HOST: sentry-kafka
|
||||
command: run post-process-forwarder --commit-batch-size 1
|
||||
volumes:
|
||||
- sentry_data:/data
|
||||
- ./sentry.conf.py:/etc/sentry/sentry.conf.py:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
# ===========================================================================
|
||||
# Relay - 事件接收网关
|
||||
# ===========================================================================
|
||||
sentry-relay:
|
||||
image: getsentry/relay:24.1.0
|
||||
container_name: sentry-relay
|
||||
depends_on:
|
||||
sentry-web:
|
||||
condition: service_healthy
|
||||
sentry-kafka:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${SENTRY_RELAY_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./relay/config.yml:/work/.relay/config.yml:ro
|
||||
- ./relay/credentials.json:/work/.relay/credentials.json:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
# ===========================================================================
|
||||
# Symbolicator - 符号化服务(崩溃堆栈解析)
|
||||
# ===========================================================================
|
||||
sentry-symbolicator:
|
||||
image: getsentry/symbolicator:24.1.0
|
||||
container_name: sentry-symbolicator
|
||||
volumes:
|
||||
- sentry_symbolicator_data:/data
|
||||
- ./symbolicator/config.yml:/etc/symbolicator/config.yml:ro
|
||||
command: run -c /etc/symbolicator/config.yml
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- sentry
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
sentry_redis_data:
|
||||
driver: local
|
||||
sentry_postgres_data:
|
||||
driver: local
|
||||
sentry_kafka_data:
|
||||
driver: local
|
||||
sentry_zookeeper_data:
|
||||
driver: local
|
||||
sentry_zookeeper_log:
|
||||
driver: local
|
||||
sentry_clickhouse_data:
|
||||
driver: local
|
||||
sentry_data:
|
||||
driver: local
|
||||
sentry_symbolicator_data:
|
||||
driver: local
|
||||
|
||||
# =============================================================================
|
||||
# Networks
|
||||
# =============================================================================
|
||||
networks:
|
||||
sentry:
|
||||
driver: bridge
|
||||
name: sentry
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# =============================================================================
|
||||
# Sentry Relay 配置
|
||||
# =============================================================================
|
||||
# Relay 是 Sentry 的事件接收网关,负责:
|
||||
# - 接收 SDK 上报的事件
|
||||
# - 事件过滤和采样
|
||||
# - 数据脱敏
|
||||
# - 转发到 Sentry 服务器
|
||||
# =============================================================================
|
||||
|
||||
relay:
|
||||
# Relay 运行模式
|
||||
# managed: 由 Sentry 服务器管理配置
|
||||
# static: 使用本地配置
|
||||
# proxy: 简单代理模式
|
||||
mode: managed
|
||||
|
||||
# 上游 Sentry 服务器地址
|
||||
upstream: "http://sentry-web:9000/"
|
||||
|
||||
# Relay 监听地址
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
# 处理配置
|
||||
processing:
|
||||
# 是否启用处理
|
||||
enabled: true
|
||||
|
||||
# Redis 配置 (用于限流)
|
||||
redis: "redis://sentry-redis:6379"
|
||||
|
||||
# Kafka 配置 (用于事件流)
|
||||
kafka_config:
|
||||
- name: bootstrap.servers
|
||||
value: "sentry-kafka:9092"
|
||||
|
||||
# 限流配置
|
||||
limits:
|
||||
# 最大请求体大小 (50MB)
|
||||
max_envelope_size: 52428800
|
||||
|
||||
# 最大并发连接数
|
||||
max_concurrent_requests: 500
|
||||
|
||||
# 缓存配置
|
||||
cache:
|
||||
# 项目配置缓存时间
|
||||
project_expiry: 300
|
||||
|
||||
# 项目配置刷新间隔
|
||||
project_grace_period: 0
|
||||
|
||||
# 健康检查
|
||||
health:
|
||||
# 健康检查端点
|
||||
enabled: true
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"secret_key": "",
|
||||
"public_key": "",
|
||||
"id": ""
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# =============================================================================
|
||||
# Sentry Configuration
|
||||
# =============================================================================
|
||||
# 此文件包含 Sentry 的自定义配置
|
||||
# 更多配置项参考: https://develop.sentry.dev/self-hosted/
|
||||
# =============================================================================
|
||||
|
||||
from sentry.conf.server import * # noqa
|
||||
|
||||
# =============================================================================
|
||||
# 基础配置
|
||||
# =============================================================================
|
||||
|
||||
# 系统 URL (用于邮件中的链接等)
|
||||
SENTRY_OPTIONS["system.url-prefix"] = env("SENTRY_SYSTEM_URL", "http://localhost:9000")
|
||||
|
||||
# 单组织模式 (推荐用于内部使用)
|
||||
SENTRY_SINGLE_ORGANIZATION = True
|
||||
|
||||
# 允许注册 (首次启动后建议设为 False)
|
||||
SENTRY_FEATURES["auth:register"] = True
|
||||
|
||||
# =============================================================================
|
||||
# 数据保留策略
|
||||
# =============================================================================
|
||||
|
||||
# 事件保留天数 (默认 90 天,可根据存储调整)
|
||||
SENTRY_OPTIONS["system.event-retention-days"] = 90
|
||||
|
||||
# =============================================================================
|
||||
# 性能配置
|
||||
# =============================================================================
|
||||
|
||||
# 采样率配置 (1.0 = 100%)
|
||||
# 生产环境可以适当降低以减少数据量
|
||||
SENTRY_OPTIONS["store.symbolicator-poll-retry-limit"] = 8
|
||||
|
||||
# =============================================================================
|
||||
# Symbolicator 配置 (崩溃堆栈符号化)
|
||||
# =============================================================================
|
||||
|
||||
SENTRY_OPTIONS["symbolicator.enabled"] = True
|
||||
SENTRY_OPTIONS["symbolicator.options"] = {
|
||||
"url": "http://sentry-symbolicator:3021",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Relay 配置 (事件接收网关)
|
||||
# =============================================================================
|
||||
|
||||
SENTRY_RELAY_WHITELIST_PK = []
|
||||
SENTRY_RELAY_OPEN_REGISTRATION = True
|
||||
|
||||
# =============================================================================
|
||||
# 功能开关
|
||||
# =============================================================================
|
||||
|
||||
# 启用 Session Replay (会话回放)
|
||||
SENTRY_FEATURES["organizations:session-replay"] = True
|
||||
|
||||
# 启用 Performance Monitoring (性能监控)
|
||||
SENTRY_FEATURES["organizations:performance-view"] = True
|
||||
|
||||
# 启用 Profiling (性能分析)
|
||||
SENTRY_FEATURES["organizations:profiling"] = True
|
||||
|
||||
# 启用 Issue 自动分组
|
||||
SENTRY_FEATURES["projects:similarity-indexing"] = True
|
||||
|
||||
# =============================================================================
|
||||
# 安全配置
|
||||
# =============================================================================
|
||||
|
||||
# CORS 配置 (允许移动端上报)
|
||||
SENTRY_ALLOW_ORIGIN = "*"
|
||||
|
||||
# CSP 报告端点
|
||||
CSP_REPORT_ONLY = True
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# =============================================================================
|
||||
# Symbolicator 配置
|
||||
# =============================================================================
|
||||
# Symbolicator 负责:
|
||||
# - 解析崩溃堆栈中的符号
|
||||
# - 将混淆后的代码映射回原始代码
|
||||
# - 支持 Android NDK、iOS 和 Flutter 的符号化
|
||||
# =============================================================================
|
||||
|
||||
# 缓存目录
|
||||
cache_dir: "/data/cache"
|
||||
|
||||
# 绑定地址
|
||||
bind: "0.0.0.0:3021"
|
||||
|
||||
# 日志级别
|
||||
logging:
|
||||
level: "info"
|
||||
|
||||
# 缓存配置
|
||||
caches:
|
||||
# 下载的符号文件缓存
|
||||
downloaded:
|
||||
max_unused_for: 604800 # 7 天
|
||||
retry_misses_after: 3600 # 1 小时后重试
|
||||
|
||||
# 派生的符号缓存
|
||||
derived:
|
||||
max_unused_for: 604800
|
||||
|
||||
# 诊断缓存
|
||||
diagnostics:
|
||||
retention: 604800
|
||||
|
||||
# 符号源配置
|
||||
sources:
|
||||
# Sentry 内置符号源
|
||||
- id: sentry:project
|
||||
type: sentry
|
||||
url: "http://sentry-web:9000/"
|
||||
|
||||
# Android 符号服务器
|
||||
- id: android
|
||||
type: http
|
||||
url: "https://symbols.mozilla.org/"
|
||||
filters:
|
||||
filetypes:
|
||||
- breakpad
|
||||
|
||||
# 处理配置
|
||||
processing:
|
||||
# 最大并发符号化请求
|
||||
max_concurrent_requests: 120
|
||||
|
||||
# 请求超时 (秒)
|
||||
request_timeout: 30
|
||||
|
||||
# 指标配置
|
||||
metrics:
|
||||
statsd: null
|
||||
|
|
@ -1,18 +1,30 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'core/storage/local_storage.dart';
|
||||
import 'core/di/injection_container.dart';
|
||||
import 'core/utils/logger.dart';
|
||||
import 'core/updater/update_service.dart';
|
||||
import 'core/updater/models/update_config.dart';
|
||||
import 'core/telemetry/telemetry_service.dart';
|
||||
import 'core/sentry/sentry_service.dart';
|
||||
import 'core/sentry/sentry_config.dart';
|
||||
|
||||
/// API 基础地址
|
||||
const String _apiBaseUrl = 'https://rwaapi.szaiai.com';
|
||||
|
||||
/// Sentry DSN (从 Sentry 后台获取)
|
||||
/// 格式: http://public_key@host:port/project_id
|
||||
/// 生产环境请替换为实际的 DSN
|
||||
const String _sentryDsn = String.fromEnvironment(
|
||||
'SENTRY_DSN',
|
||||
defaultValue: '', // 未配置时禁用 Sentry
|
||||
);
|
||||
|
||||
Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
|
||||
// Ensure Flutter bindings are initialized
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -51,25 +63,68 @@ Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
|
|||
// Create provider container with initialized dependencies
|
||||
final container = createProviderContainer(localStorage);
|
||||
|
||||
// Run app with error handling
|
||||
FlutterError.onError = (details) {
|
||||
AppLogger.e('Flutter Error', details.exception, details.stack);
|
||||
// 上报错误到遥测服务
|
||||
if (TelemetryService().isInitialized) {
|
||||
TelemetryService().logError(
|
||||
'Flutter error',
|
||||
error: details.exception,
|
||||
stackTrace: details.stack,
|
||||
extra: {'context': details.context?.toString()},
|
||||
);
|
||||
}
|
||||
};
|
||||
// 配置 Sentry
|
||||
final sentryConfig = _sentryDsn.isNotEmpty
|
||||
? (kReleaseMode
|
||||
? SentryConfig.production(dsn: _sentryDsn)
|
||||
: SentryConfig.development(dsn: _sentryDsn))
|
||||
: SentryConfig.disabled();
|
||||
|
||||
runApp(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: await builder(),
|
||||
),
|
||||
// 使用 Sentry 包装启动流程
|
||||
await SentryService.init(
|
||||
config: sentryConfig,
|
||||
appRunner: () async {
|
||||
// 设置 Flutter 错误处理
|
||||
FlutterError.onError = (details) {
|
||||
AppLogger.e('Flutter Error', details.exception, details.stack);
|
||||
|
||||
// 上报到 Sentry
|
||||
if (SentryService().isInitialized) {
|
||||
Sentry.captureException(
|
||||
details.exception,
|
||||
stackTrace: details.stack,
|
||||
);
|
||||
}
|
||||
|
||||
// 同时上报到自建遥测服务
|
||||
if (TelemetryService().isInitialized) {
|
||||
TelemetryService().logError(
|
||||
'Flutter error',
|
||||
error: details.exception,
|
||||
stackTrace: details.stack,
|
||||
extra: {'context': details.context?.toString()},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 捕获异步错误
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
AppLogger.e('Platform Error', error, stack);
|
||||
|
||||
// 上报到 Sentry
|
||||
if (SentryService().isInitialized) {
|
||||
Sentry.captureException(error, stackTrace: stack);
|
||||
}
|
||||
|
||||
// 同时上报到自建遥测服务
|
||||
if (TelemetryService().isInitialized) {
|
||||
TelemetryService().logError(
|
||||
'Platform error',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
runApp(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: await builder(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +138,24 @@ Future<void> initializeTelemetry(BuildContext context, {String? userId}) async {
|
|||
userId: userId,
|
||||
configSyncInterval: const Duration(hours: 1),
|
||||
);
|
||||
|
||||
// 同步设置 Sentry 用户信息
|
||||
if (userId != null && SentryService().isInitialized) {
|
||||
SentryService().setUser(userId: userId);
|
||||
}
|
||||
|
||||
// 设置设备上下文到 Sentry
|
||||
if (SentryService().isInitialized) {
|
||||
final deviceContext = TelemetryService().deviceContext;
|
||||
if (deviceContext != null) {
|
||||
SentryService().setDeviceContext(
|
||||
deviceId: TelemetryService().installId,
|
||||
deviceModel: deviceContext.deviceModel,
|
||||
osVersion: deviceContext.osVersion,
|
||||
appVersion: deviceContext.appVersion,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查应用更新
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
/// Sentry 配置
|
||||
class SentryConfig {
|
||||
/// Sentry DSN (Data Source Name)
|
||||
/// 格式: http://public_key@host:port/project_id
|
||||
/// 从 Sentry 后台创建项目后获取
|
||||
final String dsn;
|
||||
|
||||
/// 环境名称 (production, staging, development)
|
||||
final String environment;
|
||||
|
||||
/// 应用版本
|
||||
final String? release;
|
||||
|
||||
/// 采样率 (0.0 - 1.0)
|
||||
/// 1.0 = 100% 上报所有事件
|
||||
/// 0.1 = 10% 采样
|
||||
final double sampleRate;
|
||||
|
||||
/// 性能追踪采样率 (0.0 - 1.0)
|
||||
final double tracesSampleRate;
|
||||
|
||||
/// 是否启用 (可用于远程开关)
|
||||
final bool enabled;
|
||||
|
||||
/// 是否在调试模式下启用
|
||||
final bool enableInDebug;
|
||||
|
||||
/// 是否启用性能监控
|
||||
final bool enablePerformance;
|
||||
|
||||
/// 是否启用用户反馈
|
||||
final bool enableUserFeedback;
|
||||
|
||||
/// 是否启用崩溃免费捕获 (ANR, OOM 等)
|
||||
final bool enableNativeCrashHandling;
|
||||
|
||||
/// 是否自动收集面包屑 (用户操作轨迹)
|
||||
final bool enableBreadcrumbs;
|
||||
|
||||
/// 面包屑最大数量
|
||||
final int maxBreadcrumbs;
|
||||
|
||||
/// 是否发送默认 PII (个人身份信息)
|
||||
final bool sendDefaultPii;
|
||||
|
||||
const SentryConfig({
|
||||
required this.dsn,
|
||||
this.environment = 'production',
|
||||
this.release,
|
||||
this.sampleRate = 1.0,
|
||||
this.tracesSampleRate = 0.2,
|
||||
this.enabled = true,
|
||||
this.enableInDebug = false,
|
||||
this.enablePerformance = true,
|
||||
this.enableUserFeedback = true,
|
||||
this.enableNativeCrashHandling = true,
|
||||
this.enableBreadcrumbs = true,
|
||||
this.maxBreadcrumbs = 100,
|
||||
this.sendDefaultPii = false,
|
||||
});
|
||||
|
||||
/// 开发环境配置
|
||||
factory SentryConfig.development({required String dsn}) {
|
||||
return SentryConfig(
|
||||
dsn: dsn,
|
||||
environment: 'development',
|
||||
sampleRate: 1.0,
|
||||
tracesSampleRate: 1.0,
|
||||
enableInDebug: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 生产环境配置
|
||||
factory SentryConfig.production({required String dsn, String? release}) {
|
||||
return SentryConfig(
|
||||
dsn: dsn,
|
||||
environment: 'production',
|
||||
release: release,
|
||||
sampleRate: 1.0,
|
||||
tracesSampleRate: 0.2,
|
||||
enableInDebug: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 禁用配置 (用于关闭 Sentry)
|
||||
factory SentryConfig.disabled() {
|
||||
return const SentryConfig(
|
||||
dsn: '',
|
||||
enabled: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'sentry_service.dart';
|
||||
|
||||
/// Sentry Dio 拦截器
|
||||
/// 自动追踪 HTTP 请求,记录面包屑和性能指标
|
||||
class SentryDioInterceptor extends Interceptor {
|
||||
/// 是否启用性能追踪
|
||||
final bool enablePerformanceTracing;
|
||||
|
||||
/// 是否记录请求体 (注意隐私)
|
||||
final bool captureRequestBody;
|
||||
|
||||
/// 是否记录响应体 (注意大小和隐私)
|
||||
final bool captureResponseBody;
|
||||
|
||||
/// 敏感路径列表 (不记录详情)
|
||||
final List<String> sensitiveEndpoints;
|
||||
|
||||
/// 请求开始时间缓存
|
||||
final Map<RequestOptions, DateTime> _requestStartTimes = {};
|
||||
|
||||
/// 事务缓存
|
||||
final Map<RequestOptions, ISentrySpan> _transactions = {};
|
||||
|
||||
SentryDioInterceptor({
|
||||
this.enablePerformanceTracing = true,
|
||||
this.captureRequestBody = false,
|
||||
this.captureResponseBody = false,
|
||||
this.sensitiveEndpoints = const [
|
||||
'/auth',
|
||||
'/login',
|
||||
'/token',
|
||||
'/password',
|
||||
'/mnemonic',
|
||||
],
|
||||
});
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
_requestStartTimes[options] = DateTime.now();
|
||||
|
||||
// 启动性能追踪事务
|
||||
if (enablePerformanceTracing && SentryService().isInitialized) {
|
||||
final transaction = SentryService().startTransaction(
|
||||
name: '${options.method} ${_sanitizePath(options.path)}',
|
||||
operation: 'http.client',
|
||||
description: options.uri.toString(),
|
||||
);
|
||||
if (transaction != null) {
|
||||
_transactions[options] = transaction;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 Sentry trace header (用于分布式追踪)
|
||||
final span = Sentry.getSpan();
|
||||
if (span != null) {
|
||||
final traceHeader = span.toSentryTrace();
|
||||
options.headers['sentry-trace'] = traceHeader.value;
|
||||
|
||||
final baggageHeader = span.toBaggageHeader();
|
||||
if (baggageHeader != null) {
|
||||
options.headers['baggage'] = baggageHeader.value;
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
_recordHttpBreadcrumb(
|
||||
options: response.requestOptions,
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
_finishTransaction(response.requestOptions, response.statusCode);
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final statusCode = err.response?.statusCode;
|
||||
|
||||
_recordHttpBreadcrumb(
|
||||
options: err.requestOptions,
|
||||
statusCode: statusCode,
|
||||
error: err,
|
||||
);
|
||||
|
||||
_finishTransaction(
|
||||
err.requestOptions,
|
||||
statusCode,
|
||||
error: err,
|
||||
);
|
||||
|
||||
// 对于特定错误类型,上报到 Sentry
|
||||
if (_shouldReportError(err)) {
|
||||
SentryService().captureException(
|
||||
err,
|
||||
message: 'HTTP Error: ${err.message}',
|
||||
extras: {
|
||||
'url': _sanitizeUrl(err.requestOptions.uri.toString()),
|
||||
'method': err.requestOptions.method,
|
||||
'status_code': statusCode,
|
||||
'error_type': err.type.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
void _recordHttpBreadcrumb({
|
||||
required RequestOptions options,
|
||||
int? statusCode,
|
||||
DioException? error,
|
||||
}) {
|
||||
final startTime = _requestStartTimes.remove(options);
|
||||
final duration = startTime != null
|
||||
? DateTime.now().difference(startTime).inMilliseconds
|
||||
: null;
|
||||
|
||||
SentryService().addHttpBreadcrumb(
|
||||
url: options.uri.toString(),
|
||||
method: options.method,
|
||||
statusCode: statusCode,
|
||||
reason: error?.message,
|
||||
);
|
||||
|
||||
// 添加详细的请求上下文
|
||||
if (!_isSensitiveEndpoint(options.path)) {
|
||||
SentryService().addBreadcrumb(
|
||||
message: 'HTTP ${options.method} completed',
|
||||
category: 'http.detail',
|
||||
data: {
|
||||
'url': _sanitizeUrl(options.uri.toString()),
|
||||
'method': options.method,
|
||||
'status_code': statusCode,
|
||||
if (duration != null) 'duration_ms': duration,
|
||||
if (error != null) 'error_type': error.type.name,
|
||||
},
|
||||
level: (statusCode != null && statusCode >= 400)
|
||||
? SentryLevel.error
|
||||
: SentryLevel.info,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _finishTransaction(
|
||||
RequestOptions options,
|
||||
int? statusCode, {
|
||||
DioException? error,
|
||||
}) {
|
||||
final transaction = _transactions.remove(options);
|
||||
if (transaction == null) return;
|
||||
|
||||
// 设置状态
|
||||
if (error != null) {
|
||||
transaction.status = _mapErrorToSpanStatus(error);
|
||||
transaction.throwable = error;
|
||||
} else if (statusCode != null) {
|
||||
transaction.status = _mapStatusCodeToSpanStatus(statusCode);
|
||||
}
|
||||
|
||||
// 添加标签
|
||||
transaction.setTag('http.method', options.method);
|
||||
transaction.setTag('http.url', _sanitizePath(options.path));
|
||||
if (statusCode != null) {
|
||||
transaction.setTag('http.status_code', statusCode.toString());
|
||||
}
|
||||
|
||||
transaction.finish();
|
||||
}
|
||||
|
||||
SpanStatus _mapStatusCodeToSpanStatus(int statusCode) {
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return const SpanStatus.ok();
|
||||
} else if (statusCode == 400) {
|
||||
return const SpanStatus.invalidArgument();
|
||||
} else if (statusCode == 401) {
|
||||
return const SpanStatus.unauthenticated();
|
||||
} else if (statusCode == 403) {
|
||||
return const SpanStatus.permissionDenied();
|
||||
} else if (statusCode == 404) {
|
||||
return const SpanStatus.notFound();
|
||||
} else if (statusCode == 429) {
|
||||
return const SpanStatus.resourceExhausted();
|
||||
} else if (statusCode >= 500) {
|
||||
return const SpanStatus.internalError();
|
||||
}
|
||||
return const SpanStatus.unknownError();
|
||||
}
|
||||
|
||||
SpanStatus _mapErrorToSpanStatus(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return const SpanStatus.deadlineExceeded();
|
||||
case DioExceptionType.cancel:
|
||||
return const SpanStatus.cancelled();
|
||||
case DioExceptionType.connectionError:
|
||||
return const SpanStatus.unavailable();
|
||||
default:
|
||||
return const SpanStatus.unknownError();
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldReportError(DioException error) {
|
||||
// 不上报取消的请求
|
||||
if (error.type == DioExceptionType.cancel) return false;
|
||||
|
||||
// 不上报超时 (太常见)
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 服务端错误 (5xx) 上报
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode != null && statusCode >= 500) return true;
|
||||
|
||||
// 连接错误上报
|
||||
if (error.type == DioExceptionType.connectionError) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isSensitiveEndpoint(String path) {
|
||||
return sensitiveEndpoints.any((endpoint) =>
|
||||
path.toLowerCase().contains(endpoint.toLowerCase()));
|
||||
}
|
||||
|
||||
String _sanitizePath(String path) {
|
||||
// 移除查询参数
|
||||
final uri = Uri.tryParse(path);
|
||||
if (uri != null) {
|
||||
return uri.path;
|
||||
}
|
||||
return path.split('?').first;
|
||||
}
|
||||
|
||||
String _sanitizeUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
// 保留 scheme, host, path,移除敏感查询参数
|
||||
final sanitizedParams = <String, String>{};
|
||||
uri.queryParameters.forEach((key, value) {
|
||||
if (_isSensitiveParam(key)) {
|
||||
sanitizedParams[key] = '[REDACTED]';
|
||||
} else {
|
||||
sanitizedParams[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (sanitizedParams.isEmpty) {
|
||||
return '${uri.scheme}://${uri.host}${uri.path}';
|
||||
}
|
||||
return uri.replace(queryParameters: sanitizedParams).toString();
|
||||
} catch (_) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSensitiveParam(String key) {
|
||||
final sensitiveKeys = [
|
||||
'token',
|
||||
'password',
|
||||
'secret',
|
||||
'key',
|
||||
'auth',
|
||||
'bearer',
|
||||
'credential',
|
||||
];
|
||||
return sensitiveKeys.any((k) => key.toLowerCase().contains(k));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'sentry_service.dart';
|
||||
|
||||
/// Sentry 导航观察器
|
||||
/// 自动追踪页面导航,记录面包屑和性能指标
|
||||
class SentryNavigationObserver extends NavigatorObserver {
|
||||
final bool enablePerformanceTracing;
|
||||
|
||||
SentryNavigationObserver({
|
||||
this.enablePerformanceTracing = true,
|
||||
});
|
||||
|
||||
String? _previousRouteName;
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPush(route, previousRoute);
|
||||
_recordNavigation(
|
||||
from: _extractRouteName(previousRoute),
|
||||
to: _extractRouteName(route),
|
||||
action: 'push',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPop(route, previousRoute);
|
||||
_recordNavigation(
|
||||
from: _extractRouteName(route),
|
||||
to: _extractRouteName(previousRoute),
|
||||
action: 'pop',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||
_recordNavigation(
|
||||
from: _extractRouteName(oldRoute),
|
||||
to: _extractRouteName(newRoute),
|
||||
action: 'replace',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didRemove(route, previousRoute);
|
||||
_recordNavigation(
|
||||
from: _extractRouteName(route),
|
||||
to: _extractRouteName(previousRoute),
|
||||
action: 'remove',
|
||||
);
|
||||
}
|
||||
|
||||
void _recordNavigation({
|
||||
required String? from,
|
||||
required String? to,
|
||||
required String action,
|
||||
}) {
|
||||
final fromName = from ?? 'unknown';
|
||||
final toName = to ?? 'unknown';
|
||||
|
||||
// 记录面包屑
|
||||
SentryService().addNavigationBreadcrumb(
|
||||
from: fromName,
|
||||
to: toName,
|
||||
);
|
||||
|
||||
// 使用 Sentry 内置的导航追踪
|
||||
if (enablePerformanceTracing && SentryService().isInitialized) {
|
||||
Sentry.configureScope((scope) {
|
||||
scope.setContexts('navigation', {
|
||||
'from': fromName,
|
||||
'to': toName,
|
||||
'action': action,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_previousRouteName = toName;
|
||||
}
|
||||
|
||||
String? _extractRouteName(Route<dynamic>? route) {
|
||||
if (route == null) return null;
|
||||
|
||||
// 优先使用路由设置中的名称
|
||||
if (route.settings.name != null && route.settings.name!.isNotEmpty) {
|
||||
return route.settings.name;
|
||||
}
|
||||
|
||||
// 尝试从路由类型推断
|
||||
final routeType = route.runtimeType.toString();
|
||||
if (routeType.contains('MaterialPageRoute') ||
|
||||
routeType.contains('CupertinoPageRoute')) {
|
||||
return route.settings.name ?? 'unnamed_route';
|
||||
}
|
||||
|
||||
return routeType;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentry 内置导航观察器的扩展包装
|
||||
/// 结合自定义逻辑和 Sentry 原生功能
|
||||
class CombinedSentryNavigationObserver extends SentryNavigatorObserver {
|
||||
final SentryNavigationObserver _customObserver;
|
||||
|
||||
CombinedSentryNavigationObserver({
|
||||
bool enablePerformanceTracing = true,
|
||||
}) : _customObserver = SentryNavigationObserver(
|
||||
enablePerformanceTracing: enablePerformanceTracing,
|
||||
),
|
||||
super(
|
||||
enableAutoTransactions: enablePerformanceTracing,
|
||||
);
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPush(route, previousRoute);
|
||||
_customObserver.didPush(route, previousRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPop(route, previousRoute);
|
||||
_customObserver.didPop(route, previousRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||
_customObserver.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didRemove(route, previousRoute);
|
||||
_customObserver.didRemove(route, previousRoute);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'sentry_config.dart';
|
||||
|
||||
/// Sentry 服务
|
||||
/// 封装崩溃收集、错误上报、性能监控等功能
|
||||
class SentryService {
|
||||
static SentryService? _instance;
|
||||
SentryService._();
|
||||
|
||||
factory SentryService() {
|
||||
_instance ??= SentryService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
SentryConfig? _config;
|
||||
SentryConfig? get config => _config;
|
||||
|
||||
/// 用户ID (登录后设置)
|
||||
String? _userId;
|
||||
|
||||
/// 初始化 Sentry
|
||||
/// 必须在 runApp 之前调用
|
||||
static Future<void> init({
|
||||
required SentryConfig config,
|
||||
required AppRunner appRunner,
|
||||
}) async {
|
||||
if (config.dsn.isEmpty || !config.enabled) {
|
||||
debugPrint('[Sentry] Sentry is disabled');
|
||||
await appRunner();
|
||||
return;
|
||||
}
|
||||
|
||||
// 在调试模式下检查是否启用
|
||||
if (kDebugMode && !config.enableInDebug) {
|
||||
debugPrint('[Sentry] Sentry is disabled in debug mode');
|
||||
await appRunner();
|
||||
return;
|
||||
}
|
||||
|
||||
await SentryFlutter.init(
|
||||
(options) {
|
||||
options.dsn = config.dsn;
|
||||
options.environment = config.environment;
|
||||
options.release = config.release;
|
||||
|
||||
// 采样率
|
||||
options.sampleRate = config.sampleRate;
|
||||
options.tracesSampleRate = config.tracesSampleRate;
|
||||
|
||||
// 功能开关
|
||||
options.enableAutoPerformanceTracing = config.enablePerformance;
|
||||
options.enableUserInteractionTracing = config.enablePerformance;
|
||||
options.enableAutoNativeBreadcrumbs = config.enableBreadcrumbs;
|
||||
options.maxBreadcrumbs = config.maxBreadcrumbs;
|
||||
options.sendDefaultPii = config.sendDefaultPii;
|
||||
|
||||
// 调试
|
||||
options.debug = kDebugMode;
|
||||
|
||||
// 附加信息
|
||||
options.attachStacktrace = true;
|
||||
options.attachScreenshot = true;
|
||||
options.attachViewHierarchy = true;
|
||||
|
||||
// 过滤敏感信息
|
||||
options.beforeSend = _beforeSend;
|
||||
options.beforeBreadcrumb = _beforeBreadcrumb;
|
||||
},
|
||||
appRunner: appRunner,
|
||||
);
|
||||
|
||||
SentryService()._config = config;
|
||||
SentryService()._isInitialized = true;
|
||||
debugPrint('[Sentry] Initialized successfully');
|
||||
debugPrint('[Sentry] Environment: ${config.environment}');
|
||||
debugPrint('[Sentry] Sample Rate: ${config.sampleRate}');
|
||||
}
|
||||
|
||||
/// 发送事件前的钩子 (可过滤敏感信息)
|
||||
static SentryEvent? _beforeSend(SentryEvent event, Hint hint) {
|
||||
// 过滤掉包含敏感信息的事件
|
||||
final message = event.message?.formatted ?? '';
|
||||
if (_containsSensitiveData(message)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 过滤异常信息中的敏感数据
|
||||
if (event.exceptions != null) {
|
||||
for (final exception in event.exceptions!) {
|
||||
if (_containsSensitiveData(exception.value ?? '')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/// 面包屑前的钩子 (可过滤敏感操作)
|
||||
static Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint hint) {
|
||||
if (breadcrumb == null) return null;
|
||||
|
||||
// 过滤掉敏感路径的导航
|
||||
final category = breadcrumb.category ?? '';
|
||||
final data = breadcrumb.data ?? {};
|
||||
|
||||
// 过滤密码相关页面
|
||||
if (category == 'navigation') {
|
||||
final route = data['to'] as String? ?? '';
|
||||
if (route.contains('password') || route.contains('mnemonic')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤敏感 HTTP 请求
|
||||
if (category == 'http') {
|
||||
final url = data['url'] as String? ?? '';
|
||||
if (url.contains('auth') || url.contains('token')) {
|
||||
// 移除敏感数据但保留请求记录
|
||||
return breadcrumb.copyWith(
|
||||
data: {'url': _sanitizeUrl(url), 'method': data['method']},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
/// 检查是否包含敏感数据
|
||||
static bool _containsSensitiveData(String text) {
|
||||
final sensitivePatterns = [
|
||||
RegExp(r'password', caseSensitive: false),
|
||||
RegExp(r'mnemonic', caseSensitive: false),
|
||||
RegExp(r'private.?key', caseSensitive: false),
|
||||
RegExp(r'secret', caseSensitive: false),
|
||||
RegExp(r'bearer\s+[\w-]+', caseSensitive: false),
|
||||
];
|
||||
|
||||
for (final pattern in sensitivePatterns) {
|
||||
if (pattern.hasMatch(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 清理 URL 中的敏感参数
|
||||
static String _sanitizeUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final sanitizedParams = <String, String>{};
|
||||
uri.queryParameters.forEach((key, value) {
|
||||
if (_isSensitiveParam(key)) {
|
||||
sanitizedParams[key] = '[REDACTED]';
|
||||
} else {
|
||||
sanitizedParams[key] = value;
|
||||
}
|
||||
});
|
||||
return uri.replace(queryParameters: sanitizedParams).toString();
|
||||
} catch (_) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否是敏感参数
|
||||
static bool _isSensitiveParam(String key) {
|
||||
final sensitiveKeys = ['token', 'password', 'secret', 'key', 'auth'];
|
||||
return sensitiveKeys.any((k) => key.toLowerCase().contains(k));
|
||||
}
|
||||
|
||||
// ========== 用户信息 ==========
|
||||
|
||||
/// 设置用户信息 (登录后调用)
|
||||
void setUser({
|
||||
required String userId,
|
||||
String? email,
|
||||
String? username,
|
||||
Map<String, dynamic>? extras,
|
||||
}) {
|
||||
if (!_isInitialized) return;
|
||||
|
||||
_userId = userId;
|
||||
Sentry.configureScope((scope) {
|
||||
scope.setUser(SentryUser(
|
||||
id: userId,
|
||||
email: email,
|
||||
username: username,
|
||||
data: extras,
|
||||
));
|
||||
});
|
||||
debugPrint('[Sentry] User set: $userId');
|
||||
}
|
||||
|
||||
/// 清除用户信息 (退出登录时调用)
|
||||
void clearUser() {
|
||||
if (!_isInitialized) return;
|
||||
|
||||
_userId = null;
|
||||
Sentry.configureScope((scope) {
|
||||
scope.setUser(null);
|
||||
});
|
||||
debugPrint('[Sentry] User cleared');
|
||||
}
|
||||
|
||||
// ========== 错误上报 ==========
|
||||
|
||||
/// 捕获异常
|
||||
Future<SentryId> captureException(
|
||||
dynamic exception, {
|
||||
dynamic stackTrace,
|
||||
String? message,
|
||||
Map<String, dynamic>? extras,
|
||||
SentryLevel? level,
|
||||
}) async {
|
||||
if (!_isInitialized) return SentryId.empty();
|
||||
|
||||
return await Sentry.captureException(
|
||||
exception,
|
||||
stackTrace: stackTrace,
|
||||
withScope: (scope) {
|
||||
if (message != null) {
|
||||
scope.setContexts('error_context', {'message': message});
|
||||
}
|
||||
if (extras != null) {
|
||||
scope.setContexts('extras', extras);
|
||||
}
|
||||
if (level != null) {
|
||||
scope.level = level;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 捕获消息 (非异常的错误/警告)
|
||||
Future<SentryId> captureMessage(
|
||||
String message, {
|
||||
SentryLevel level = SentryLevel.info,
|
||||
Map<String, dynamic>? extras,
|
||||
}) async {
|
||||
if (!_isInitialized) return SentryId.empty();
|
||||
|
||||
return await Sentry.captureMessage(
|
||||
message,
|
||||
level: level,
|
||||
withScope: (scope) {
|
||||
if (extras != null) {
|
||||
scope.setContexts('extras', extras);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 面包屑 (用户操作轨迹) ==========
|
||||
|
||||
/// 添加面包屑
|
||||
void addBreadcrumb({
|
||||
required String message,
|
||||
String? category,
|
||||
Map<String, dynamic>? data,
|
||||
SentryLevel level = SentryLevel.info,
|
||||
}) {
|
||||
if (!_isInitialized) return;
|
||||
|
||||
Sentry.addBreadcrumb(Breadcrumb(
|
||||
message: message,
|
||||
category: category,
|
||||
data: data,
|
||||
level: level,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
}
|
||||
|
||||
/// 添加导航面包屑
|
||||
void addNavigationBreadcrumb({
|
||||
required String from,
|
||||
required String to,
|
||||
}) {
|
||||
addBreadcrumb(
|
||||
message: 'Navigation: $from -> $to',
|
||||
category: 'navigation',
|
||||
data: {'from': from, 'to': to},
|
||||
);
|
||||
}
|
||||
|
||||
/// 添加 HTTP 请求面包屑
|
||||
void addHttpBreadcrumb({
|
||||
required String url,
|
||||
required String method,
|
||||
int? statusCode,
|
||||
String? reason,
|
||||
}) {
|
||||
addBreadcrumb(
|
||||
message: '$method $url',
|
||||
category: 'http',
|
||||
data: {
|
||||
'url': _sanitizeUrl(url),
|
||||
'method': method,
|
||||
if (statusCode != null) 'status_code': statusCode,
|
||||
if (reason != null) 'reason': reason,
|
||||
},
|
||||
level: (statusCode != null && statusCode >= 400)
|
||||
? SentryLevel.error
|
||||
: SentryLevel.info,
|
||||
);
|
||||
}
|
||||
|
||||
/// 添加用户操作面包屑
|
||||
void addUserActionBreadcrumb({
|
||||
required String action,
|
||||
String? target,
|
||||
Map<String, dynamic>? data,
|
||||
}) {
|
||||
addBreadcrumb(
|
||||
message: action,
|
||||
category: 'user_action',
|
||||
data: {
|
||||
if (target != null) 'target': target,
|
||||
...?data,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 上下文信息 ==========
|
||||
|
||||
/// 设置额外上下文
|
||||
void setContext(String key, Map<String, dynamic> value) {
|
||||
if (!_isInitialized) return;
|
||||
|
||||
Sentry.configureScope((scope) {
|
||||
scope.setContexts(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
/// 设置标签
|
||||
void setTag(String key, String value) {
|
||||
if (!_isInitialized) return;
|
||||
|
||||
Sentry.configureScope((scope) {
|
||||
scope.setTag(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
/// 设置设备信息
|
||||
void setDeviceContext({
|
||||
String? deviceId,
|
||||
String? deviceModel,
|
||||
String? osVersion,
|
||||
String? appVersion,
|
||||
}) {
|
||||
setContext('device_info', {
|
||||
if (deviceId != null) 'device_id': deviceId,
|
||||
if (deviceModel != null) 'device_model': deviceModel,
|
||||
if (osVersion != null) 'os_version': osVersion,
|
||||
if (appVersion != null) 'app_version': appVersion,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 性能监控 ==========
|
||||
|
||||
/// 开始事务 (用于性能追踪)
|
||||
ISentrySpan? startTransaction({
|
||||
required String name,
|
||||
required String operation,
|
||||
String? description,
|
||||
}) {
|
||||
if (!_isInitialized || _config?.enablePerformance != true) return null;
|
||||
|
||||
return Sentry.startTransaction(
|
||||
name,
|
||||
operation,
|
||||
description: description,
|
||||
bindToScope: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 包装异步操作并追踪性能
|
||||
Future<T> traceAsync<T>({
|
||||
required String name,
|
||||
required String operation,
|
||||
required Future<T> Function() fn,
|
||||
}) async {
|
||||
final transaction = startTransaction(name: name, operation: operation);
|
||||
|
||||
try {
|
||||
final result = await fn();
|
||||
transaction?.status = const SpanStatus.ok();
|
||||
return result;
|
||||
} catch (e) {
|
||||
transaction?.status = const SpanStatus.internalError();
|
||||
transaction?.throwable = e;
|
||||
rethrow;
|
||||
} finally {
|
||||
await transaction?.finish();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 用户反馈 ==========
|
||||
|
||||
/// 收集用户反馈
|
||||
Future<void> captureUserFeedback({
|
||||
required SentryId eventId,
|
||||
required String comments,
|
||||
String? email,
|
||||
String? name,
|
||||
}) async {
|
||||
if (!_isInitialized || _config?.enableUserFeedback != true) return;
|
||||
|
||||
await Sentry.captureUserFeedback(SentryUserFeedback(
|
||||
eventId: eventId,
|
||||
comments: comments,
|
||||
email: email,
|
||||
name: name,
|
||||
));
|
||||
}
|
||||
|
||||
// ========== 工具方法 ==========
|
||||
|
||||
/// 获取当前用户ID
|
||||
String? get userId => _userId;
|
||||
|
||||
/// 强制上传所有待发送事件
|
||||
Future<void> flush() async {
|
||||
if (!_isInitialized) return;
|
||||
// Sentry SDK 会自动处理上传,这里可以添加额外逻辑
|
||||
}
|
||||
|
||||
/// 关闭 Sentry
|
||||
Future<void> close() async {
|
||||
if (!_isInitialized) return;
|
||||
await Sentry.close();
|
||||
_isInitialized = false;
|
||||
debugPrint('[Sentry] Closed');
|
||||
}
|
||||
|
||||
/// 重置实例 (用于测试)
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import '../storage/secure_storage.dart';
|
|||
import '../storage/storage_keys.dart';
|
||||
import '../errors/exceptions.dart';
|
||||
import '../telemetry/telemetry_service.dart';
|
||||
import '../sentry/sentry_service.dart';
|
||||
|
||||
/// 设备硬件信息 (存储在 deviceName 字段中)
|
||||
class DeviceHardwareInfo {
|
||||
|
|
@ -598,6 +599,12 @@ class AccountService {
|
|||
debugPrint('$_tag _saveAccountData() - 设置TelemetryService userId: ${response.userSerialNum}');
|
||||
}
|
||||
|
||||
// 设置 Sentry 用户信息
|
||||
if (SentryService().isInitialized) {
|
||||
SentryService().setUser(userId: response.userSerialNum);
|
||||
debugPrint('$_tag _saveAccountData() - 设置SentryService userId: ${response.userSerialNum}');
|
||||
}
|
||||
|
||||
debugPrint('$_tag _saveAccountData() - 账号数据保存完成');
|
||||
}
|
||||
|
||||
|
|
@ -945,6 +952,12 @@ class AccountService {
|
|||
debugPrint('$_tag _saveRecoverAccountData() - 设置TelemetryService userId: ${response.userSerialNum}');
|
||||
}
|
||||
|
||||
// 设置 Sentry 用户信息
|
||||
if (SentryService().isInitialized) {
|
||||
SentryService().setUser(userId: response.userSerialNum);
|
||||
debugPrint('$_tag _saveRecoverAccountData() - 设置SentryService userId: ${response.userSerialNum}');
|
||||
}
|
||||
|
||||
debugPrint('$_tag _saveRecoverAccountData() - 恢复账号数据保存完成');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||
import '../storage/secure_storage.dart';
|
||||
import '../storage/storage_keys.dart';
|
||||
import '../telemetry/telemetry_service.dart';
|
||||
import '../sentry/sentry_service.dart';
|
||||
|
||||
/// 账号信息摘要(用于账号列表显示)
|
||||
class AccountSummary {
|
||||
|
|
@ -151,6 +152,12 @@ class MultiAccountService {
|
|||
debugPrint('$_tag switchToAccount() - 设置TelemetryService userId: $userSerialNum');
|
||||
}
|
||||
|
||||
// 设置 Sentry 用户信息
|
||||
if (SentryService().isInitialized) {
|
||||
SentryService().setUser(userId: userSerialNum);
|
||||
debugPrint('$_tag switchToAccount() - 设置SentryService userId: $userSerialNum');
|
||||
}
|
||||
|
||||
debugPrint('$_tag switchToAccount() - 切换成功');
|
||||
return true;
|
||||
}
|
||||
|
|
@ -276,6 +283,12 @@ class MultiAccountService {
|
|||
debugPrint('$_tag logoutCurrentAccount() - 清除TelemetryService userId');
|
||||
}
|
||||
|
||||
// 清除 Sentry 用户信息
|
||||
if (SentryService().isInitialized) {
|
||||
SentryService().clearUser();
|
||||
debugPrint('$_tag logoutCurrentAccount() - 清除SentryService userId');
|
||||
}
|
||||
|
||||
debugPrint('$_tag logoutCurrentAccount() - 退出完成,已清除 ${keysToClear.length} 个状态');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import '../../../../core/storage/secure_storage.dart';
|
|||
import '../../../../core/storage/storage_keys.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/telemetry/telemetry_service.dart';
|
||||
import '../../../../core/sentry/sentry_service.dart';
|
||||
|
||||
enum AuthStatus {
|
||||
initial,
|
||||
|
|
@ -122,6 +123,10 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||
if (userSerialNum != null && TelemetryService().isInitialized) {
|
||||
TelemetryService().setUserId(userSerialNum);
|
||||
}
|
||||
// 设置 Sentry 用户信息
|
||||
if (userSerialNum != null && SentryService().isInitialized) {
|
||||
SentryService().setUser(userId: userSerialNum);
|
||||
}
|
||||
} else if (isAccountCreated) {
|
||||
// 账号已创建但钱包未完成(可能正在生成或未备份)
|
||||
state = state.copyWith(
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ dependencies:
|
|||
device_info_plus: ^11.0.0
|
||||
sqflite: ^2.3.0
|
||||
|
||||
# 崩溃收集与错误追踪
|
||||
sentry_flutter: ^8.10.1
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
Loading…
Reference in New Issue