diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4d5e23a5..f0d07d4b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 \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 \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 \nEOF\n)\")" ], "deny": [], "ask": [] diff --git a/backend/infrastructure/README.md b/backend/infrastructure/README.md index 3a905ed3..4c4b429f 100644 --- a/backend/infrastructure/README.md +++ b/backend/infrastructure/README.md @@ -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** | ## 扩展配置 diff --git a/backend/infrastructure/sentry/.env.example b/backend/infrastructure/sentry/.env.example new file mode 100644 index 00000000..4c8cd6f4 --- /dev/null +++ b/backend/infrastructure/sentry/.env.example @@ -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 diff --git a/backend/infrastructure/sentry/README.md b/backend/infrastructure/sentry/README.md new file mode 100644 index 00000000..f1b5e5da --- /dev/null +++ b/backend/infrastructure/sentry/README.md @@ -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) diff --git a/backend/infrastructure/sentry/clickhouse/config.xml b/backend/infrastructure/sentry/clickhouse/config.xml new file mode 100644 index 00000000..45679010 --- /dev/null +++ b/backend/infrastructure/sentry/clickhouse/config.xml @@ -0,0 +1,27 @@ + + + + + + warning + true + + + + 0.8 + + + 100 + + + 4096 + + + + + 10000000000 + 0.01 + lz4 + + + diff --git a/backend/infrastructure/sentry/deploy.sh b/backend/infrastructure/sentry/deploy.sh new file mode 100644 index 00000000..0f17f61f --- /dev/null +++ b/backend/infrastructure/sentry/deploy.sh @@ -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 diff --git a/backend/infrastructure/sentry/docker-compose.yml b/backend/infrastructure/sentry/docker-compose.yml new file mode 100644 index 00000000..b443e883 --- /dev/null +++ b/backend/infrastructure/sentry/docker-compose.yml @@ -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 diff --git a/backend/infrastructure/sentry/relay/config.yml b/backend/infrastructure/sentry/relay/config.yml new file mode 100644 index 00000000..3bbbad84 --- /dev/null +++ b/backend/infrastructure/sentry/relay/config.yml @@ -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 diff --git a/backend/infrastructure/sentry/relay/credentials.json b/backend/infrastructure/sentry/relay/credentials.json new file mode 100644 index 00000000..550d97cf --- /dev/null +++ b/backend/infrastructure/sentry/relay/credentials.json @@ -0,0 +1,5 @@ +{ + "secret_key": "", + "public_key": "", + "id": "" +} diff --git a/backend/infrastructure/sentry/sentry.conf.py b/backend/infrastructure/sentry/sentry.conf.py new file mode 100644 index 00000000..dabb5804 --- /dev/null +++ b/backend/infrastructure/sentry/sentry.conf.py @@ -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 diff --git a/backend/infrastructure/sentry/symbolicator/config.yml b/backend/infrastructure/sentry/symbolicator/config.yml new file mode 100644 index 00000000..9a5d9c6d --- /dev/null +++ b/backend/infrastructure/sentry/symbolicator/config.yml @@ -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 diff --git a/frontend/mobile-app/lib/bootstrap.dart b/frontend/mobile-app/lib/bootstrap.dart index 83e6f106..775c803c 100644 --- a/frontend/mobile-app/lib/bootstrap.dart +++ b/frontend/mobile-app/lib/bootstrap.dart @@ -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 bootstrap(FutureOr Function() builder) async { // Ensure Flutter bindings are initialized WidgetsFlutterBinding.ensureInitialized(); @@ -51,25 +63,68 @@ Future bootstrap(FutureOr 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 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, + ); + } + } } /// 检查应用更新 diff --git a/frontend/mobile-app/lib/core/sentry/sentry_config.dart b/frontend/mobile-app/lib/core/sentry/sentry_config.dart new file mode 100644 index 00000000..83b6b419 --- /dev/null +++ b/frontend/mobile-app/lib/core/sentry/sentry_config.dart @@ -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, + ); + } +} diff --git a/frontend/mobile-app/lib/core/sentry/sentry_dio_interceptor.dart b/frontend/mobile-app/lib/core/sentry/sentry_dio_interceptor.dart new file mode 100644 index 00000000..66ed7491 --- /dev/null +++ b/frontend/mobile-app/lib/core/sentry/sentry_dio_interceptor.dart @@ -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 sensitiveEndpoints; + + /// 请求开始时间缓存 + final Map _requestStartTimes = {}; + + /// 事务缓存 + final Map _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 = {}; + 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)); + } +} diff --git a/frontend/mobile-app/lib/core/sentry/sentry_navigation_observer.dart b/frontend/mobile-app/lib/core/sentry/sentry_navigation_observer.dart new file mode 100644 index 00000000..65e07fa1 --- /dev/null +++ b/frontend/mobile-app/lib/core/sentry/sentry_navigation_observer.dart @@ -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 route, Route? previousRoute) { + super.didPush(route, previousRoute); + _recordNavigation( + from: _extractRouteName(previousRoute), + to: _extractRouteName(route), + action: 'push', + ); + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + _recordNavigation( + from: _extractRouteName(route), + to: _extractRouteName(previousRoute), + action: 'pop', + ); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + _recordNavigation( + from: _extractRouteName(oldRoute), + to: _extractRouteName(newRoute), + action: 'replace', + ); + } + + @override + void didRemove(Route route, Route? 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? 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 route, Route? previousRoute) { + super.didPush(route, previousRoute); + _customObserver.didPush(route, previousRoute); + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + _customObserver.didPop(route, previousRoute); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + _customObserver.didReplace(newRoute: newRoute, oldRoute: oldRoute); + } + + @override + void didRemove(Route route, Route? previousRoute) { + super.didRemove(route, previousRoute); + _customObserver.didRemove(route, previousRoute); + } +} diff --git a/frontend/mobile-app/lib/core/sentry/sentry_service.dart b/frontend/mobile-app/lib/core/sentry/sentry_service.dart new file mode 100644 index 00000000..c9ee5a20 --- /dev/null +++ b/frontend/mobile-app/lib/core/sentry/sentry_service.dart @@ -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 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 = {}; + 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? 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 captureException( + dynamic exception, { + dynamic stackTrace, + String? message, + Map? 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 captureMessage( + String message, { + SentryLevel level = SentryLevel.info, + Map? 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? 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? data, + }) { + addBreadcrumb( + message: action, + category: 'user_action', + data: { + if (target != null) 'target': target, + ...?data, + }, + ); + } + + // ========== 上下文信息 ========== + + /// 设置额外上下文 + void setContext(String key, Map 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 traceAsync({ + required String name, + required String operation, + required Future 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 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 flush() async { + if (!_isInitialized) return; + // Sentry SDK 会自动处理上传,这里可以添加额外逻辑 + } + + /// 关闭 Sentry + Future close() async { + if (!_isInitialized) return; + await Sentry.close(); + _isInitialized = false; + debugPrint('[Sentry] Closed'); + } + + /// 重置实例 (用于测试) + static void reset() { + _instance = null; + } +} diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 650d4783..a6494bff 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -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() - 恢复账号数据保存完成'); } diff --git a/frontend/mobile-app/lib/core/services/multi_account_service.dart b/frontend/mobile-app/lib/core/services/multi_account_service.dart index 1f20d980..ed168469 100644 --- a/frontend/mobile-app/lib/core/services/multi_account_service.dart +++ b/frontend/mobile-app/lib/core/services/multi_account_service.dart @@ -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} 个状态'); } diff --git a/frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart b/frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart index c4cc5f29..80cf0e38 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart @@ -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 { if (userSerialNum != null && TelemetryService().isInitialized) { TelemetryService().setUserId(userSerialNum); } + // 设置 Sentry 用户信息 + if (userSerialNum != null && SentryService().isInitialized) { + SentryService().setUser(userId: userSerialNum); + } } else if (isAccountCreated) { // 账号已创建但钱包未完成(可能正在生成或未备份) state = state.copyWith( diff --git a/frontend/mobile-app/pubspec.yaml b/frontend/mobile-app/pubspec.yaml index 937c0347..b4d0f938 100644 --- a/frontend/mobile-app/pubspec.yaml +++ b/frontend/mobile-app/pubspec.yaml @@ -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: