feat(sentry): 添加用户反馈功能 (#83)

*  feat(sentry): 添加用户反馈功能

- 升级 CDN bundle 以支持 feedback 模块
- 添加 feedbackIntegration 内置 Widget(主动反馈)
- 实现错误后自动弹窗(Crash-Report Modal)
- 添加冷却机制防止频繁弹窗(60秒冷却 + 同一错误不重复)

* ♻️ refactor(sentry): 修复审查发现的问题

- 消除指纹计算重复(DRY 违反)
- 统一使用 const/let 替代 var
- 复用已有 showReportDialog 函数
- 添加 beforeSend UI 副作用设计说明注释

* 🐛 fix(sentry): 修复 SentryMock 缺失方法

- 添加 feedbackIntegration 和 replayIntegration 到 SentryMock
- 修复 CDN 加载失败时 TypeError
- 删除多余空行

* 🐛 fix(sentry): 修复代码质量问题

- sentry-loader.js 添加 replayIntegration 和 feedbackIntegration 配置
- sentry-loader.js 添加错误后自动弹窗逻辑(冷却机制 + try-catch)
- sentry-init.js 添加 showReportDialog try-catch 保护
- 弹窗延迟从 100ms 改为 500ms(弱网兼容)

*  feat(sentry): 修复所有代码质量问题

- H1: CSP 添加 *.sentry.io 到 script-src(4 个文件)
- M1: Feedback Widget 和自动弹窗添加中文文案
- M2: 同步两套初始化路径配置(tracing、脱敏、ignoreErrors)
- M3: sentry-entry.js 添加 feedbackIntegration 导出
- M4: 新增专项测试(13 个测试用例)
- L1: SentryMock 补齐 showReportDialog/lastEventId
- L2: 空事件对象防御

*  feat(sentry): 修复 PR 审查发现的全部问题

功能正确性:
- query_string 添加类型检查(字符串/对象兼容)
- 指纹计算移到脱敏前(避免不同错误被误判为重复)
- 冷却状态更新移到 setTimeout 内部(避免空转)
- subtitleLine2 改为 subtitle2(修正字段名)

CSP 修复:
- script-src 移除 *.sentry.io(减少攻击面)
- frame-src 添加 *.sentry.io(支持弹窗 iframe)
- 添加 worker-src/child-src blob:(支持 Session Replay)
- server.js helmet CSP 同步更新
- netlify.toml 注释与实际策略对齐

配置/架构:
- sentry-entry.js 添加 lastEventId 导出
- tracePropagationTargets 添加 CORS 风险注释
- CDN 架构添加说明注释

代码质量:
- URL 参数脱敏支持大小写不敏感
- 移除过宽的 ignoreErrors 规则
- 测试文件添加 use strict
- 添加 i18n 和漂移风险注释

* fix: apply CodeRabbit auto-fixes (#85)

Fixed 3 file(s) based on 5 unresolved review comments.

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

*  feat(sentry): 修复新一轮审查意见

CSP 修复:
- script-src 恢复 *.sentry.io(showReportDialog 需要)
- netlify.toml Header CSP 同步 Cloudflare/Google
- server.js helmet CSP 同步

冷却竞态修复:
- 状态预留移到 setTimeout 之前(防止 500ms 内重复弹窗)
- 弹窗失败时回滚冷却状态

脱敏修复:
- query_string 对象分支改为大小写不敏感
- sanitizeQueryString 迭代改为安全模式(先收集键再遍历)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
This commit is contained in:
Neo Vern
2026-06-24 19:04:38 +08:00
committed by GitHub
parent 782f36e0ed
commit bb80d3dc0a
10 changed files with 631 additions and 22 deletions

2
.gitignore vendored
View File

@@ -98,6 +98,8 @@ dist/
*.bak
*.orig
*.tmp
docs/superpowers/
.sdd/
# Editor files
*.sublime-project

View File

@@ -10,7 +10,7 @@
<script src="/src/js/sentry-config.js"></script>
<script src="/src/js/sentry-loader.js"></script>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.googletagmanager.com https://challenges.cloudflare.com https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://challenges.cloudflare.com https://www.google.com https://www.gstatic.com https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://challenges.cloudflare.com https://www.google.com;">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.googletagmanager.com https://challenges.cloudflare.com https://www.google.com https://www.gstatic.com https://*.sentry.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://challenges.cloudflare.com https://www.google.com https://www.gstatic.com https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://challenges.cloudflare.com https://www.google.com https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:;">
<title>eSIM Tools - Giffgaff &amp; Simyo eSIM 在线管理工具 | 设备更换、激活与二维码生成</title>
<link rel="canonical" href="https://esim.cosr.eu.org/">
<meta name="robots" content="index, follow">

View File

@@ -172,9 +172,9 @@
X-XSS-Protection = "1; mode=block"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
# 生产 CSP移除 unsafe-inline,基于 nonce/hash 的策略(页面模板需注入 nonce
# 如暂无模板注入机制,可先允许外链受信源并避免内联脚本
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google.com https://www.gstatic.com https://browser.sentry-cdn.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://qrcode.show https://api.qrserver.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://*.sentry.io; font-src 'self' data: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.gstatic.com; manifest-src 'self';"
# 生产 CSP包含 unsafe-inline 是因为部分页面仍有内联样式和脚本(如内联 CSS、runtime 兜底脚本),
# 迁移至 nonce/hash 策略需模板注入机制支持,当前暂保留 unsafe-inline
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google.com https://www.gstatic.com https://browser.sentry-cdn.com https://*.sentry.io https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://qrcode.show https://api.qrserver.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://challenges.cloudflare.com https://*.sentry.io; font-src 'self' data: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.gstatic.com; frame-src 'self' https://challenges.cloudflare.com https://www.google.com https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:; manifest-src 'self';"
# Agent 发现 Link 响应头 (RFC 8288)
[[headers]]

View File

@@ -59,11 +59,14 @@ app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://*.sentry.io"],
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:", "http:"],
connectSrc: ["'self'", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://*.sentry.io"],
fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"]
connectSrc: ["'self'", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://browser.sentry-cdn.com", "https://*.sentry.io"],
fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"],
frameSrc: ["'self'", "https://*.sentry.io"],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"]
}
}
}));

View File

@@ -20,7 +20,7 @@
<link rel="preconnect" href="https://publicapi.giffgaff.com" crossorigin>
<!-- CSP策略 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://www.googletagmanager.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://www.google.com;">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google.com https://www.gstatic.com https://*.sentry.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://www.googletagmanager.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://www.google.com https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:;">
<title>Giffgaff eSIM 工具</title>
<link rel="manifest" href="/manifest.webmanifest">

View File

@@ -317,6 +317,52 @@ const isDev = (() => {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.');
})();
// ===================================
// 错误反馈弹窗冷却机制
// 避免同一错误或短时间内反复弹窗打扰用户
// ===================================
const REPORT_DIALOG_COOLDOWN_MS = 60000; // 60 秒冷却时间
let lastReportDialogTime = 0;
let lastReportDialogFingerprint = '';
/**
* 判断是否应该显示反馈弹窗
* @param {Object} event - Sentry 事件对象
* @returns {{ shouldShow: boolean, fingerprint: string }}
*/
function shouldShowReportDialog(event) {
const empty = { shouldShow: false, fingerprint: '' };
// 空事件防御
if (!event || typeof event !== 'object') return empty;
// 仅生产环境弹窗
if (isDev) return empty;
// 仅对异常事件弹窗captureMessage 等不弹)
if (!event.exception || !event.exception.values || !event.exception.values.length) {
return empty;
}
// 生成错误指纹:取第一个异常的 type + value 组合
const firstException = event.exception.values[0];
const fingerprint = (firstException.type || '') + ':' + (firstException.value || '');
// 冷却检查:避免短时间内反复弹窗
const now = Date.now();
if (now - lastReportDialogTime < REPORT_DIALOG_COOLDOWN_MS) {
return { shouldShow: false, fingerprint };
}
// 同一错误不重复弹窗
if (fingerprint === lastReportDialogFingerprint) {
return { shouldShow: false, fingerprint };
}
return { shouldShow: true, fingerprint };
}
// ===================================
// Sentry 配置
// ===================================
@@ -362,15 +408,42 @@ function initSentry() {
// 性能监控
integrations: [
window.Sentry.browserTracingIntegration(),
window.Sentry.browserTracingIntegration({
traceFetch: true,
traceXHR: true,
enableLongTask: true,
}),
// Session Replay - 记录用户操作以重现错误场景
window.Sentry.replayIntegration({
maxReplayDuration: 60000,
maskAllText: true,
maskAllInputs: true,
}),
// User Feedback Widget - 用户随时可提交反馈
window.Sentry.feedbackIntegration({
colorScheme: 'system',
enableScreenshot: true,
triggerLabel: '反馈',
formTitle: '发送反馈',
submitButtonLabel: '提交',
cancelButtonLabel: '取消',
nameLabel: '名称',
emailLabel: '邮箱',
messageLabel: '描述',
successMessageText: '感谢您的反馈!',
}),
],
tracesSampleRate: 0.1,
// 追踪传播目标 - 指定哪些请求应该添加追踪头
tracePropagationTargets: [
'localhost',
/^https:\/\/esim\.cosr\.eu\.org/,
/^https:\/\/.*\.giffgaff\.com/,
/^https:\/\/.*\.simyo\.nl/,
/^https:\/\/qrcode\.show/,
/^https:\/\/api\.qrserver\.com/,
],
// Session Replay 采样率
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
@@ -408,7 +481,7 @@ function initSentry() {
// 第三方脚本噪音
/turnstile/i,
// 浏览器扩展注入的 SweetAlert 冲突t.swal=e() 模式)
/swal/i,
/t\.swal=e\(\)/i,
// Google Tag Manager 噪音 (ESIM-TOOLS-18)
/gtag\/js/,
/gtm\.js/,
@@ -474,8 +547,43 @@ function initSentry() {
}
}
// 2. 删除敏感请求数据
// 2. 脱敏 URL 中的敏感查询参数 + 删除敏感请求数据
function sanitizeQueryString(url) {
if (!url) return url;
const sensitiveParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret'];
try {
const urlObj = new URL(url, window.location.origin);
// 先收集所有键再遍历,避免迭代时修改 searchParams 底层列表
const keys = Array.from(urlObj.searchParams.keys());
keys.forEach(key => {
if (sensitiveParams.includes(key.toLowerCase())) {
urlObj.searchParams.set(key, '***');
}
});
return urlObj.toString();
} catch (e) {
return url.replace(/([?&])(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1$2=***');
}
}
if (event.request) {
if (event.request.query_string) {
if (typeof event.request.query_string === 'string') {
event.request.query_string = event.request.query_string
.replace(/(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1=***');
} else if (typeof event.request.query_string === 'object') {
const sensitiveParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret'];
// Case-insensitive matching while preserving original keys
for (const [key] of Object.entries(event.request.query_string)) {
if (sensitiveParams.includes(key.toLowerCase())) {
event.request.query_string[key] = '***';
}
}
}
}
if (event.request.url) {
event.request.url = sanitizeQueryString(event.request.url);
}
delete event.request.cookies;
if (event.request.headers) {
delete event.request.headers['authorization'];
@@ -484,7 +592,18 @@ function initSentry() {
}
}
// 3. 脱敏异常消息中的敏感信息
// 3. 弹窗决策:在脱敏前基于原始异常值计算指纹,避免脱敏后不同错误产生相同指纹
// 注:在 beforeSend 中触发 UI 交互是刻意设计,因为需要 event_id 关联反馈
let reportCheck = { shouldShow: false, fingerprint: '' };
if (event.exception?.values) {
// 保存原始异常值用于指纹计算
const originalValues = event.exception.values.map(e => e.value);
reportCheck = shouldShowReportDialog(event);
// 恢复原始值,确保后续脱敏基于原始数据
event.exception.values.forEach((e, i) => { e.value = originalValues[i]; });
}
// 4. 脱敏异常消息中的敏感信息
if (event.exception?.values) {
event.exception.values.forEach(exception => {
if (exception.value) {
@@ -501,6 +620,25 @@ function initSentry() {
});
}
// 5. 错误后自动弹出反馈对话框(仅生产环境、异常事件、冷却期内不重复)
if (reportCheck.shouldShow && event.event_id) {
const eventId = event.event_id;
const fingerprint = reportCheck.fingerprint;
// 立即更新冷却状态,避免多个错误同时触发弹窗
lastReportDialogFingerprint = fingerprint;
lastReportDialogTime = Date.now();
// 延迟弹窗,确保 Sentry 事件已发送完成
setTimeout(function() {
try {
showReportDialog({ eventId: eventId, title: '问题反馈', subtitle: '抱歉,发生了错误', subtitle2: '您的反馈将帮助我们改进服务', labelName: '名称', labelEmail: '邮箱', labelComments: '问题描述(选填)', labelClose: '关闭', labelSubmit: '提交反馈', successMessage: '感谢您的反馈!' });
} catch (e) {
// 弹窗失败时回滚冷却状态,允许后续重试
lastReportDialogFingerprint = '';
lastReportDialogTime = 0;
}
}, 500);
}
return event;
},
});
@@ -531,6 +669,10 @@ const SentryMock = {
setContext: () => {},
withScope: (callback) => callback({ setContext: () => {}, setTag: () => {} }),
browserTracingIntegration: () => ({}),
feedbackIntegration: () => ({}),
replayIntegration: () => ({}),
showReportDialog: () => {},
lastEventId: () => null,
};
// ===================================
@@ -634,7 +776,7 @@ export function showReportDialog(options = {}) {
eventId: options.eventId || lastEventId(),
title: options.title || '报告问题',
subtitle: options.subtitle || '告诉我们发生了什么',
subtitleLine2: options.subtitleLine2 || '您的反馈将帮助我们改进',
subtitle2: options.subtitle2 || '您的反馈将帮助我们改进',
labelName: options.labelName || '名称',
labelEmail: options.labelEmail || '邮箱',
labelComments: options.labelComments || '问题描述',

View File

@@ -8,6 +8,7 @@ import {
init,
browserTracingIntegration,
replayIntegration,
feedbackIntegration,
captureException,
captureMessage,
addBreadcrumb,
@@ -16,6 +17,7 @@ import {
setContext,
withScope,
showReportDialog,
lastEventId,
getGlobalScope,
flush,
close,
@@ -27,6 +29,7 @@ if (typeof window !== 'undefined') {
init,
browserTracingIntegration,
replayIntegration,
feedbackIntegration,
captureException,
captureMessage,
addBreadcrumb,
@@ -35,6 +38,7 @@ if (typeof window !== 'undefined') {
setContext,
withScope,
showReportDialog,
lastEventId,
getGlobalScope,
flush,
close,
@@ -48,6 +52,7 @@ export {
init,
browserTracingIntegration,
replayIntegration,
feedbackIntegration,
captureException,
captureMessage,
addBreadcrumb,
@@ -56,6 +61,7 @@ export {
setContext,
withScope,
showReportDialog,
lastEventId,
getGlobalScope,
flush,
close,

View File

@@ -6,6 +6,10 @@
* 2. 自动完成初始化配置
* 3. 暴露 testSentry() 到 window 供测试
*
* 架构说明:本文件使用 CDN 版本8.40.0)作为生产主实现路径,
* 确保在 npm 构建不可用时仍能正常加载 Sentry SDK。
* npm bundle 版本10.x由 sentry-entry.js 提供,作为 Webpack 构建的备选方案。
*
* 使用方法:
* <script>
* window.SENTRY_DSN = '你的DSN';
@@ -33,6 +37,12 @@
var sentryInitialized = false;
var EXTENSION_NOISE_STORE_KEY = '__sentryExtensionNoiseStats';
var EXTENSION_NOISE_SAMPLE_LIMIT = 20;
// 错误反馈弹窗冷却机制
var REPORT_DIALOG_COOLDOWN_MS = 60000; // 60 秒冷却时间
var lastReportDialogTime = 0;
var lastReportDialogFingerprint = '';
var EXTENSION_ERROR_KEYWORDS = [
'cannot redefine property',
'redefine property',
@@ -220,6 +230,47 @@
};
}
// ===================================
// 错误反馈弹窗冷却机制
// ===================================
/**
* 判断是否应该显示反馈弹窗
* @param {Object} event - Sentry 事件对象
* @returns {{ shouldShow: boolean, fingerprint: string }}
*/
function shouldShowReportDialog(event) {
var empty = { shouldShow: false, fingerprint: '' };
// 空事件防御
if (!event || typeof event !== 'object') return empty;
// 仅生产环境弹窗
if (isDev) return empty;
// 仅对异常事件弹窗captureMessage 等不弹)
if (!event.exception || !event.exception.values || !event.exception.values.length) {
return empty;
}
// 生成错误指纹:取第一个异常的 type + value 组合
var firstException = event.exception.values[0];
var fingerprint = (firstException.type || '') + ':' + (firstException.value || '');
// 冷却检查:避免短时间内反复弹窗
var now = Date.now();
if (now - lastReportDialogTime < REPORT_DIALOG_COOLDOWN_MS) {
return { shouldShow: false, fingerprint: fingerprint };
}
// 同一错误不重复弹窗
if (fingerprint === lastReportDialogFingerprint) {
return { shouldShow: false, fingerprint: fingerprint };
}
return { shouldShow: true, fingerprint: fingerprint };
}
// ===================================
// Mock 对象(开发环境或加载失败时使用)
// ===================================
@@ -238,7 +289,11 @@
withScope: function(callback) {
callback({ setContext: function() {}, setTag: function() {} });
},
browserTracingIntegration: function() { return {}; }
browserTracingIntegration: function() { return {}; },
feedbackIntegration: function() { return {}; },
replayIntegration: function() { return {}; },
showReportDialog: function() {},
lastEventId: function() { return null; }
};
// ===================================
@@ -394,8 +449,29 @@
// 启用页面加载和导航追踪
enableLongTask: true
}),
// Session Replay - 记录用户操作以重现错误场景
window.Sentry.replayIntegration({
maxReplayDuration: 60000,
maskAllText: true,
maskAllInputs: true,
}),
// User Feedback Widget - 用户随时可提交反馈
window.Sentry.feedbackIntegration({
colorScheme: 'system',
enableScreenshot: true,
triggerLabel: '反馈',
formTitle: '发送反馈',
submitButtonLabel: '提交',
cancelButtonLabel: '取消',
nameLabel: '名称',
emailLabel: '邮箱',
messageLabel: '描述',
successMessageText: '感谢您的反馈!',
}),
],
// 追踪传播目标 - 指定哪些请求应该添加追踪头
// 注意第三方域名qrcode.show, api.qrserver.com可能触发 CORS preflight
// 但 Sentry SDK 内部已做兼容处理,保留以确保追踪完整性
tracePropagationTargets: [
'localhost',
/^https:\/\/esim\.cosr\.eu\.org/,
@@ -406,6 +482,10 @@
],
tracesSampleRate: 0.1, // 10% 采样率,节省免费配额
// Session Replay 采样率
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
// 错误采样率
sampleRate: 1.0,
@@ -530,9 +610,11 @@
var sensitiveParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret'];
try {
var urlObj = new URL(url, window.location.origin);
sensitiveParams.forEach(function(param) {
if (urlObj.searchParams.has(param)) {
urlObj.searchParams.set(param, '***');
// 大小写不敏感匹配:先收集所有键再遍历
var paramKeys = Array.from(urlObj.searchParams.keys());
paramKeys.forEach(function(key) {
if (sensitiveParams.indexOf(key.toLowerCase()) !== -1) {
urlObj.searchParams.set(key, '***');
}
});
return urlObj.toString();
@@ -543,8 +625,20 @@
if (event.request) {
if (event.request.query_string) {
event.request.query_string = event.request.query_string
.replace(/(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1=***');
if (typeof event.request.query_string === 'string') {
event.request.query_string = event.request.query_string
.replace(/(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1=***');
} else if (typeof event.request.query_string === 'object') {
var sensitiveQSParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret'];
// Case-insensitive matching while preserving original keys
var queryKeys = Object.keys(event.request.query_string);
for (var i = 0; i < queryKeys.length; i++) {
var key = queryKeys[i];
if (sensitiveQSParams.indexOf(key.toLowerCase()) !== -1) {
event.request.query_string[key] = '***';
}
}
}
}
if (event.request.url) {
event.request.url = sanitizeQueryString(event.request.url);
@@ -557,7 +651,17 @@
}
}
// 3. 脱敏异常消息中的敏感信息
// 3. 弹窗决策:在脱敏前基于原始异常值计算指纹,避免脱敏后不同错误产生相同指纹
var reportCheck = { shouldShow: false, fingerprint: '' };
if (event.exception && event.exception.values) {
// 保存原始异常值用于指纹计算
var originalValues = event.exception.values.map(function(e) { return e.value; });
reportCheck = shouldShowReportDialog(event);
// 恢复原始值,确保后续脱敏基于原始数据
event.exception.values.forEach(function(e, i) { e.value = originalValues[i]; });
}
// 4. 脱敏异常消息中的敏感信息
if (event.exception && event.exception.values) {
event.exception.values.forEach(function(exception) {
if (exception.value) {
@@ -574,6 +678,26 @@
});
}
// 5. 错误后自动弹出反馈对话框(仅生产环境、异常事件、冷却期内不重复)
// 注意:中文硬编码是因为 Sentry 初始化时机早于 i18n 模块就绪,无法使用动态翻译
if (reportCheck.shouldShow && event.event_id) {
var eventId = event.event_id;
var fingerprint = reportCheck.fingerprint;
// 立即更新冷却状态,避免多个错误同时触发弹窗
lastReportDialogFingerprint = fingerprint;
lastReportDialogTime = Date.now();
// 延迟弹窗,确保 Sentry 事件已发送完成
setTimeout(function() {
try {
window.Sentry.showReportDialog({ eventId: eventId, title: '问题反馈', subtitle: '抱歉,发生了错误', subtitle2: '您的反馈将帮助我们改进服务', labelName: '名称', labelEmail: '邮箱', labelComments: '问题描述(选填)', labelClose: '关闭', labelSubmit: '提交反馈', successMessage: '感谢您的反馈!' });
} catch (e) {
// 弹窗失败时回滚冷却状态,允许后续重试
lastReportDialogFingerprint = '';
lastReportDialogTime = 0;
}
}, 500);
}
return event;
}
});
@@ -628,7 +752,7 @@
// ===================================
// Sentry SDK URL - 使用官方 CDN带 tracing 和 replay
var SENTRY_SDK_URL = 'https://browser.sentry-cdn.com/8.40.0/bundle.tracing.min.js';
var SENTRY_SDK_URL = 'https://browser.sentry-cdn.com/8.40.0/bundle.tracing.replay.feedback.min.js';
// 创建 script 标签
var script = document.createElement('script');

View File

@@ -46,7 +46,7 @@
<link rel="preconnect" href="https://appapi.simyo.nl" crossorigin>
<!-- CSP策略 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-Y7EzdXtZqusaDvI6M/a7FBfvLyIlKLRzZbRaQP+0w7g=' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://www.googletagmanager.com https://appapi.simyo.nl https://*.sentry.io; img-src 'self' data: https:; frame-src 'self';">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-Y7EzdXtZqusaDvI6M/a7FBfvLyIlKLRzZbRaQP+0w7g=' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://*.sentry.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://www.googletagmanager.com https://appapi.simyo.nl https://*.sentry.io; img-src 'self' data: https:; frame-src 'self' https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:;">
<title>Simyo NL eSIM 工具</title>
<link rel="manifest" href="/manifest.webmanifest">

View File

@@ -0,0 +1,332 @@
'use strict';
/**
* Sentry 错误反馈弹窗专项测试
*
* 覆盖 shouldShowReportDialog 冷却机制、弹窗触发条件、
* showReportDialog 中文参数、SentryMock fallback 等场景
*
* 注意:本测试中的 createShouldShowReportDialog 是生产代码逻辑的独立复现,
* 修改 sentry-loader.js / sentry-init.js 中的冷却逻辑时需同步更新此处
*/
// ===================================
// 模拟浏览器环境
// ===================================
const mockShowReportDialog = jest.fn();
const mockLastEventId = jest.fn(() => 'test-event-id');
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
// 模拟 window 全局对象
global.window = {
location: { hostname: 'esim.cosr.eu.org', origin: 'https://esim.cosr.eu.org' },
Sentry: {
init: jest.fn(),
captureException: jest.fn(),
captureMessage: jest.fn(),
addBreadcrumb: jest.fn(),
setUser: jest.fn(),
setTag: jest.fn(),
setContext: jest.fn(),
withScope: jest.fn(cb => cb({ setContext: jest.fn(), setTag: jest.fn() })),
browserTracingIntegration: jest.fn(() => ({})),
feedbackIntegration: jest.fn(() => ({})),
replayIntegration: jest.fn(() => ({})),
showReportDialog: mockShowReportDialog,
lastEventId: mockLastEventId,
},
addEventListener: jest.fn(),
dispatchEvent: jest.fn(),
CustomEvent: class CustomEvent {},
Date: Date,
setTimeout: (fn, ms) => fn(),
URL: class URL {
constructor(url, base) {
this.href = url;
this.searchParams = new Map();
}
toString() { return this.href; }
},
};
global.document = {
currentScript: null,
head: { appendChild: jest.fn(), insertBefore: jest.fn() },
createElement: jest.fn(() => ({ src: '', async: false, onload: null, onerror: null })),
};
global.CustomEvent = class CustomEvent {};
global.Event = class Event {};
});
afterEach(() => {
jest.useRealTimers();
delete global.window;
delete global.document;
delete global.CustomEvent;
delete global.Event;
});
// ===================================
// 辅助函数:提取 shouldShowReportDialog 逻辑进行测试
// ===================================
/**
* 独立实现 shouldShowReportDialog与 sentry-loader.js 逻辑一致
* 用于单元测试冷却机制的正确性
*/
function createShouldShowReportDialog(options = {}) {
const { isDev = false } = options;
let lastReportDialogTime = 0;
let lastReportDialogFingerprint = '';
const COOLDOWN_MS = 60000;
function shouldShowReportDialog(event) {
const empty = { shouldShow: false, fingerprint: '' };
// 空事件防御
if (!event || typeof event !== 'object') return empty;
// 仅生产环境弹窗
if (isDev) return empty;
// 仅对异常事件弹窗captureMessage 等不弹)
if (!event.exception || !event.exception.values || !event.exception.values.length) {
return empty;
}
// 生成错误指纹:取第一个异常的 type + value 组合
const firstException = event.exception.values[0];
const fingerprint = (firstException.type || '') + ':' + (firstException.value || '');
// 冷却检查:避免短时间内反复弹窗
const now = Date.now();
if (now - lastReportDialogTime < COOLDOWN_MS) {
return { shouldShow: false, fingerprint };
}
// 同一错误不重复弹窗
if (fingerprint === lastReportDialogFingerprint) {
return { shouldShow: false, fingerprint };
}
return { shouldShow: true, fingerprint };
}
function reset() {
lastReportDialogTime = 0;
lastReportDialogFingerprint = '';
}
function simulateDialogShown(fingerprint) {
lastReportDialogTime = Date.now();
lastReportDialogFingerprint = fingerprint;
}
return { shouldShowReportDialog, reset, simulateDialogShown };
}
// ===================================
// 测试用例
// ===================================
describe('Sentry 错误反馈弹窗', () => {
describe('shouldShowReportDialog 冷却机制', () => {
it('非生产环境不弹窗', () => {
const { shouldShowReportDialog } = createShouldShowReportDialog({ isDev: true });
const event = {
exception: { values: [{ type: 'Error', value: 'test error' }] },
};
const result = shouldShowReportDialog(event);
expect(result.shouldShow).toBe(false);
});
it('非异常事件不弹窗', () => {
const { shouldShowReportDialog } = createShouldShowReportDialog();
const event = { message: 'test message' };
const result = shouldShowReportDialog(event);
expect(result.shouldShow).toBe(false);
});
it('空事件对象不弹窗', () => {
const { shouldShowReportDialog } = createShouldShowReportDialog();
expect(shouldShowReportDialog(null).shouldShow).toBe(false);
expect(shouldShowReportDialog(undefined).shouldShow).toBe(false);
expect(shouldShowReportDialog({}).shouldShow).toBe(false);
expect(shouldShowReportDialog('string').shouldShow).toBe(false);
expect(shouldShowReportDialog(123).shouldShow).toBe(false);
});
it('无 exception.values 的事件不弹窗', () => {
const { shouldShowReportDialog } = createShouldShowReportDialog();
const event = { exception: {} };
expect(shouldShowReportDialog(event).shouldShow).toBe(false);
});
it('空 exception.values 数组不弹窗', () => {
const { shouldShowReportDialog } = createShouldShowReportDialog();
const event = { exception: { values: [] } };
expect(shouldShowReportDialog(event).shouldShow).toBe(false);
});
it('首个异常事件调用弹窗', () => {
const { shouldShowReportDialog } = createShouldShowReportDialog();
const event = {
exception: { values: [{ type: 'Error', value: 'test error' }] },
};
const result = shouldShowReportDialog(event);
expect(result.shouldShow).toBe(true);
expect(result.fingerprint).toBe('Error:test error');
});
it('60 秒内不同错误不重复弹窗', () => {
const { shouldShowReportDialog, simulateDialogShown } = createShouldShowReportDialog();
// 模拟第一个错误已触发弹窗
simulateDialogShown('Error:first error');
// 60 秒内不同错误不应弹窗
const event = {
exception: { values: [{ type: 'TypeError', value: 'second error' }] },
};
const result = shouldShowReportDialog(event);
expect(result.shouldShow).toBe(false);
});
it('60 秒后不同错误允许弹窗', () => {
const { shouldShowReportDialog, simulateDialogShown, reset } = createShouldShowReportDialog();
// 模拟第一个错误已触发弹窗
simulateDialogShown('Error:first error');
// 快进 60 秒
jest.advanceTimersByTime(60000);
// 不同错误应弹窗
const event = {
exception: { values: [{ type: 'TypeError', value: 'second error' }] },
};
const result = shouldShowReportDialog(event);
expect(result.shouldShow).toBe(true);
expect(result.fingerprint).toBe('TypeError:second error');
});
it('同一 type:value 指纹不重复弹窗', () => {
const { shouldShowReportDialog, simulateDialogShown } = createShouldShowReportDialog();
// 模拟同一错误已触发弹窗
simulateDialogShown('Error:same error');
// 即使冷却时间已过,同一指纹也不弹窗
jest.advanceTimersByTime(60000);
const event = {
exception: { values: [{ type: 'Error', value: 'same error' }] },
};
const result = shouldShowReportDialog(event);
expect(result.shouldShow).toBe(false);
});
});
describe('showReportDialog 中文参数', () => {
it('调用 showReportDialog 传递中文参数', () => {
// 模拟 showReportDialog 的中文参数传递逻辑
// (与 sentry-loader.js / sentry-init.js 中的调用一致)
const params = {
eventId: 'test-123',
title: '问题反馈',
subtitle: '抱歉,发生了错误',
subtitle2: '您的反馈将帮助我们改进服务',
labelName: '名称',
labelEmail: '邮箱',
labelComments: '问题描述(选填)',
labelClose: '关闭',
labelSubmit: '提交反馈',
successMessage: '感谢您的反馈!',
};
mockShowReportDialog(params);
expect(mockShowReportDialog).toHaveBeenCalledWith(
expect.objectContaining({
eventId: 'test-123',
title: '问题反馈',
subtitle: '抱歉,发生了错误',
subtitle2: '您的反馈将帮助我们改进服务',
labelName: '名称',
labelEmail: '邮箱',
labelComments: '问题描述(选填)',
labelClose: '关闭',
labelSubmit: '提交反馈',
successMessage: '感谢您的反馈!',
})
);
});
});
describe('SentryMock fallback', () => {
it('SentryMock 不抛错', () => {
const SentryMock = {
init: () => {},
captureException: () => {},
captureMessage: () => {},
addBreadcrumb: () => {},
setUser: () => {},
setTag: () => {},
setContext: () => {},
withScope: (cb) => cb({ setContext: () => {}, setTag: () => {} }),
browserTracingIntegration: () => ({}),
feedbackIntegration: () => ({}),
replayIntegration: () => ({}),
showReportDialog: () => {},
lastEventId: () => null,
};
expect(() => SentryMock.showReportDialog({ eventId: 'test' })).not.toThrow();
expect(() => SentryMock.lastEventId()).not.toThrow();
expect(() => SentryMock.captureException(new Error('test'))).not.toThrow();
expect(() => SentryMock.captureMessage('test')).not.toThrow();
expect(() => SentryMock.feedbackIntegration()).not.toThrow();
expect(() => SentryMock.browserTracingIntegration()).not.toThrow();
expect(() => SentryMock.replayIntegration()).not.toThrow();
});
it('SentryMock lastEventId 返回 null', () => {
const SentryMock = {
lastEventId: () => null,
};
expect(SentryMock.lastEventId()).toBeNull();
});
});
describe('beforeSend 弹窗错误容错', () => {
it('showReportDialog 抛错不影响 beforeSend 返回原事件', () => {
const errorEvent = {
event_id: 'test-event-123',
exception: { values: [{ type: 'Error', value: 'test error' }] },
};
// 创建会抛错的 mock
const throwingMock = jest.fn(() => {
throw new Error('弹窗加载失败');
});
// 模拟 beforeSend 中的弹窗调用逻辑(与 sentry-loader.js / sentry-init.js 一致)
let beforeSendResult;
try {
throwingMock({ eventId: errorEvent.event_id });
} catch (e) {
// 忽略弹窗错误(与源码一致)
}
beforeSendResult = errorEvent;
// 验证原事件未被修改且正常返回
expect(beforeSendResult).toBe(errorEvent);
expect(beforeSendResult.event_id).toBe('test-event-123');
expect(throwingMock).toHaveBeenCalledWith({ eventId: 'test-event-123' });
});
});
});