Files
flutter-template/lib/core/observability/sentry_setup.dart
T
SkyJourney 61017f1c39 chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库
7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系
5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter

通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
2026-05-14 12:51:05 +08:00

161 lines
6.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/foundation.dart';
import 'package:sunny_mochi/core/config/env.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// 包装 [SentryFlutter.init],统一注入:
/// - DSN 来自 [Env.sentryDsn](空 DSN 跳过实际上报,便于 P0 兜底)
/// - PII 脱敏(健康类 App 强约束,详见 docs/flutter-architecture-design.md §十一.5
/// - 屏蔽 screenshot / view hierarchy(含敏感页面)
///
/// 调用方在 main.dart 包一层:
/// ```dart
/// await SentrySetup.init(appRunner: () => runApp(...));
/// ```
class SentrySetup {
static Future<void> init({required Future<void> Function() appRunner}) async {
if (Env.sentryDsn.isEmpty) {
// P0 / 本地开发兜底:未配置 DSN 时直接跑应用,不绕道 Sentry init
if (kDebugMode) {
debugPrint('[Sentry] DSN 为空,跳过 init(仅本地,生产必须配置)');
}
await appRunner();
return;
}
await SentryFlutter.init(
(options) {
options
..dsn = Env.sentryDsn
..environment = Env.name
..tracesSampleRate = Env.isProd ? 0.2 : 1.0
..debug = kDebugMode
// === 健康数据 PII 脱敏(强约束)===
..sendDefaultPii = false
..beforeSend = scrubPii;
},
appRunner: appRunner,
);
}
/// 丢弃可能含敏感数据的字段,仅保留错误归因所需的元信息。
///
/// 脱敏覆盖:
/// - `request.data` / `request.cookies`(可能含登录密码 / token / 手机号)
/// - `request.headers` 仅保留白名单
/// - `user.email` / `user.ipAddress`(仅保留匿名 userId
/// - `breadcrumbs.message` 内 token / 手机号 / 身份证 / 长 base64 串掩盖
///
/// **可见**visible for testing — 直接调用以验证脱敏行为。
@visibleForTesting
static SentryEvent? scrubPii(SentryEvent event, Hint hint) {
// Sentry 9.x:直接 mutate 字段(copyWith 已 deprecated)。
event
..request = _scrubRequest(event.request)
..user = _scrubUser(event.user)
..breadcrumbs = _scrubBreadcrumbs(event.breadcrumbs)
// R-SEC-3 / 2026-05-10extra 字段防御性清理 —
// SDK 自动 instrumentationframe tracking 等)可能写入 Widget 名称 / 路由参数,
// 路由参数可能含手机号 / userId。用白名单过滤而非全清,保留调试上下文。
..extra = _scrubExtra(event.extra);
return event;
}
static SentryRequest? _scrubRequest(SentryRequest? request) {
if (request == null) return null;
return SentryRequest(
url: request.url,
method: request.method,
// queryString / cookies / data 强制清空(可能含 token / 手机号 / 密码 / 健康数据)
headers: _scrubHeaders(request.headers),
apiTarget: request.apiTarget,
);
}
static SentryUser? _scrubUser(SentryUser? user) {
if (user == null) return null;
// 仅保留匿名 userId(要求上层调用方已哈希);email/IP 强制清空
return SentryUser(id: user.id);
}
/// 仅保留非敏感 header(白名单策略)。
static Map<String, String>? _scrubHeaders(Map<String, String>? headers) {
if (headers == null) return null;
const allow = {'content-type', 'accept', 'user-agent', 'x-request-id'};
return {
for (final e in headers.entries)
if (allow.contains(e.key.toLowerCase())) e.key: e.value,
};
}
/// 把 breadcrumbs 中 message 内的 token / 手机号 / 身份证 / 长 base64 串掩盖。
static List<Breadcrumb>? _scrubBreadcrumbs(List<Breadcrumb>? crumbs) {
if (crumbs == null) return null;
return crumbs
.map(
(c) => Breadcrumb(
timestamp: c.timestamp,
message: c.message == null ? null : maskSensitive(c.message!),
category: c.category,
level: c.level,
type: c.type,
// data 中也可能含敏感字段,按白名单清空(仅保留 method/status_code/url 这种)
data: _scrubBreadcrumbData(c.data),
),
)
.toList();
}
static Map<String, dynamic>? _scrubBreadcrumbData(Map<String, dynamic>? data) {
if (data == null) return null;
const allow = {'method', 'status_code', 'url', 'reason'};
return {
for (final e in data.entries)
if (allow.contains(e.key))
e.key: e.value is String ? maskSensitive(e.value as String) : e.value,
};
}
/// 仅保留非敏感 extra(白名单 + 字符串值掩盖)。
///
/// 业务侧 `Sentry.captureException(extra: {...})` 应主动避免传 PII
/// 此函数作为防御层,覆盖 SDK 自动写入场景。
static Map<String, dynamic>? _scrubExtra(Map<String, dynamic>? extra) {
if (extra == null) return null;
// 路由 / Widget 上下文是调试有用且非 PII 的元信息,保留;
// 其他业务自定义字段全部清除(业务方明确知道字段含义时应在 captureException 时
// 自行 mask 后放进来,再加进 allow 列表)。
const allow = {'route', 'route_name', 'widget', 'screen', 'flavor'};
return {
for (final e in extra.entries)
if (allow.contains(e.key))
e.key: e.value is String ? maskSensitive(e.value as String) : e.value,
};
}
/// 把字符串中的常见敏感模式替换为 `***`。
///
/// 模式:
/// - 手机号:11 位 1 开头数字
/// - 身份证:18 位(最后一位可 X)
/// - JWT / 长 base64 token:连续 32+ 个 base64url 字符
/// - 形如 `Bearer xxx` / `token=xxx` 的整段
@visibleForTesting
static String maskSensitive(String input) {
return input
// Bearer / Authorization-style
.replaceAll(
RegExp(
r'(?:bearer\s+|token[=:]\s*|access_token[=:]\s*|refresh_token[=:]\s*)[A-Za-z0-9._\-+/=]{12,}',
caseSensitive: false,
),
'***',
)
// 长 base64 / JWT
.replaceAll(RegExp(r'[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}'), '***')
// 中国大陆手机号
.replaceAll(RegExp(r'(?<!\d)1[3-9]\d{9}(?!\d)'), '***')
// 身份证号
.replaceAll(RegExp(r'(?<!\d)\d{17}[\dXx](?!\d)'), '***');
}
}