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:
hailin 2025-12-15 07:15:13 -08:00
parent cc06638e0e
commit 80b74e9877
20 changed files with 2294 additions and 26 deletions

View File

@ -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": []

View File

@ -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** |
## 扩展配置

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
{
"secret_key": "",
"public_key": "",
"id": ""
}

View File

@ -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

View File

@ -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

View File

@ -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,
);
}
}
}
///

View File

@ -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,
);
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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() - 恢复账号数据保存完成');
}

View File

@ -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} 个状态');
}

View File

@ -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(

View File

@ -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: