Commit 189bdee1 by luoqi

feat(web): Sentry 网络面包屑 URL 归一化(去患者标识,保留路由形状)

医疗系统 fetch/xhr 面包屑 URL 带 patientId/病历号等 PII,整段被服务端打 [Filtered] 又看不出接口。
新增 sentry-scrub.sanitizeUrl:段级 id(uuid/纯数字/长hex/病历号)→ :id、query 全去掉;
client/server/edge 三处 Sentry.init 挂 beforeBreadcrumb。既能看调了哪个接口,又不落患者标识。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent fb67dc3b
Pipeline #3391 failed in 0 seconds
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
* 自部署 Sentry 23.6.1:只做 error + 基础 tracing,不开 Session Replay(老服务器不全)。 * 自部署 Sentry 23.6.1:只做 error + 基础 tracing,不开 Session Replay(老服务器不全)。
*/ */
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import { scrubBreadcrumb } from '@/lib/sentry-scrub';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
...@@ -13,6 +14,8 @@ if (dsn) { ...@@ -13,6 +14,8 @@ if (dsn) {
dsn, dsn,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV, environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1), tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
// 网络面包屑 URL 归一化(去患者标识,保留路由形状)
beforeBreadcrumb: scrubBreadcrumb,
}); });
} }
......
/**
* Sentry 面包屑脱敏 —— PAC 是医疗系统,fetch/xhr 面包屑的 URL 常带患者标识
* (patientId / medicalRecordNumber / externalId / tenant/clinic id 等)。
* 直接上报会把 PII 落进 Sentry;整段被服务端打成 [Filtered] 又看不出调的哪个接口。
* 折中:客户端先把 URL 归一化 —— 路由形状保留、真实 id 换成 :id、query 整段丢。
* GET https://host/pac/v1/plans/<uuid>/full?x=1 → GET https://host/pac/v1/plans/:id/full
*/
import type { Breadcrumb } from '@sentry/nextjs';
/** 一个路径段是否"像 id"(UUID / 纯数字 / 长 hex / 病历号样式)→ 该被 :id 替换。 */
function looksLikeId(seg: string): boolean {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return true; // uuid
if (/^\d{3,}$/.test(seg)) return true; // 纯数字 id(如 1930118)
if (/^[0-9a-f]{16,}$/i.test(seg)) return true; // 长 hex(tenant/org/clinic id 等 32 位)
if (/[A-Za-z].*\d{4,}|\d{4,}.*[A-Za-z]/.test(seg)) return true; // 字母+4位以上数字混合(病历号 ZJ0A034947)
return false;
}
/** 归一化 URL:段级 id → :id,query 全去掉;解析失败退回只砍 query。 */
export function sanitizeUrl(url: string): string {
try {
const u = new URL(url, 'http://_'); // 提供 base 以支持相对 URL
const path = u.pathname
.split('/')
.map((seg) => (seg && looksLikeId(seg) ? ':id' : seg))
.join('/');
const origin = u.origin && u.origin !== 'http://_' ? u.origin : ''; // 相对 URL 的假 base 不带出
return origin + path;
} catch {
return url.split('?')[0] ?? url;
}
}
/** beforeBreadcrumb 入口:只处理网络类面包屑的 URL,其余原样返回。 */
export function scrubBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb {
const cat = breadcrumb.category;
if ((cat === 'fetch' || cat === 'xhr') && breadcrumb.data && typeof breadcrumb.data.url === 'string') {
breadcrumb.data.url = sanitizeUrl(breadcrumb.data.url);
}
return breadcrumb;
}
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
* pac-web 目前基本不用 edge,但 Next 约定齐全一份,避免 edge 报错逃逸。缺省 DSN → no-op。 * pac-web 目前基本不用 edge,但 Next 约定齐全一份,避免 edge 报错逃逸。缺省 DSN → no-op。
*/ */
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import { scrubBreadcrumb } from '@/lib/sentry-scrub';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
...@@ -11,5 +12,7 @@ if (dsn) { ...@@ -11,5 +12,7 @@ if (dsn) {
dsn, dsn,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV, environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1), tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
// 网络面包屑 URL 归一化(去患者标识,保留路由形状)
beforeBreadcrumb: scrubBreadcrumb,
}); });
} }
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
* 复用同一个 pac-web 项目 DSN(NEXT_PUBLIC_SENTRY_DSN);缺省 → no-op。 * 复用同一个 pac-web 项目 DSN(NEXT_PUBLIC_SENTRY_DSN);缺省 → no-op。
*/ */
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
import { scrubBreadcrumb } from '@/lib/sentry-scrub';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
...@@ -11,5 +12,7 @@ if (dsn) { ...@@ -11,5 +12,7 @@ if (dsn) {
dsn, dsn,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV, environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1), tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
// 网络面包屑 URL 归一化(去患者标识,保留路由形状)
beforeBreadcrumb: scrubBreadcrumb,
}); });
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment