233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
import { type AxiosError, type AxiosRequestConfig } from "axios";
|
||
|
||
import { apiClient } from "@/services/api/request";
|
||
|
||
export type ClientDiagnosticLevel = "info" | "warning" | "error";
|
||
export type ClientDiagnosticCategory = "navigation" | "action" | "request" | "runtime" | "task" | "plugin";
|
||
|
||
export type ClientDiagnosticEvent = {
|
||
id: string;
|
||
timestamp: string;
|
||
level: ClientDiagnosticLevel;
|
||
category: ClientDiagnosticCategory;
|
||
code?: string;
|
||
message: string;
|
||
route?: string;
|
||
durationMs?: number;
|
||
httpStatus?: number;
|
||
requestId?: string;
|
||
traceId?: string;
|
||
taskId?: string;
|
||
projectId?: string;
|
||
canvasId?: string;
|
||
nodes_before?: number;
|
||
nodes_after?: number;
|
||
stack?: string;
|
||
};
|
||
|
||
type DiagnosticEventInput = Omit<ClientDiagnosticEvent, "id" | "timestamp"> & { timestamp?: string };
|
||
type RequestDiagnosticMeta = { startedAt: number; traceId: string };
|
||
|
||
const maxEvents = 500;
|
||
const events: ClientDiagnosticEvent[] = [];
|
||
const requestMeta = new WeakMap<object, RequestDiagnosticMeta>();
|
||
let initialized = false;
|
||
let scopedUserId = "";
|
||
let activeTraceId = createDiagnosticId("trace");
|
||
|
||
export function initializeClientDiagnostics() {
|
||
if (initialized || typeof window === "undefined") return;
|
||
initialized = true;
|
||
window.addEventListener("error", (event) => {
|
||
recordDiagnosticEvent({
|
||
level: "error",
|
||
category: "runtime",
|
||
message: event.message || "页面运行异常",
|
||
stack: event.error instanceof Error ? event.error.stack : undefined,
|
||
});
|
||
});
|
||
window.addEventListener("unhandledrejection", (event) => {
|
||
const reason = event.reason instanceof Error ? event.reason : new Error(String(event.reason || "未处理的 Promise 异常"));
|
||
recordDiagnosticEvent({ level: "error", category: "runtime", message: reason.message, stack: reason.stack });
|
||
});
|
||
|
||
apiClient.interceptors.request.use(
|
||
(config) => {
|
||
const route = normalizeRoute(config.url);
|
||
if (config.method?.toLowerCase() === "post" && route === "/tasks") {
|
||
activeTraceId = createDiagnosticId("trace");
|
||
}
|
||
const traceId = activeTraceId;
|
||
setTraceHeader(config, traceId);
|
||
requestMeta.set(config, { startedAt: performance.now(), traceId });
|
||
return config;
|
||
},
|
||
(error) => {
|
||
recordDiagnosticEvent({ level: "error", category: "request", message: "请求发送前失败" });
|
||
return Promise.reject(error);
|
||
},
|
||
);
|
||
apiClient.interceptors.response.use(
|
||
(response) => {
|
||
const config = response.config;
|
||
const meta = requestMeta.get(config);
|
||
const route = normalizeRoute(config.url);
|
||
const status = response.status;
|
||
const businessCode = response.data && typeof response.data === "object" && "code" in response.data ? Number(response.data.code) : 0;
|
||
const requestId = readHeader(response.headers, "x-request-id");
|
||
recordDiagnosticEvent({
|
||
level: status >= 400 || businessCode !== 0 ? "error" : "info",
|
||
category: "request",
|
||
code: businessCode !== 0 ? String(businessCode) : undefined,
|
||
message: String(config.method || "GET").toUpperCase() + " " + route,
|
||
route,
|
||
durationMs: meta ? Math.max(0, Math.round(performance.now() - meta.startedAt)) : undefined,
|
||
httpStatus: status,
|
||
requestId,
|
||
traceId: meta?.traceId,
|
||
});
|
||
return response;
|
||
},
|
||
(error: AxiosError) => {
|
||
const config = error.config;
|
||
const meta = config ? requestMeta.get(config) : undefined;
|
||
const response = error.response;
|
||
const body = response?.data && typeof response.data === "object" ? (response.data as { code?: number; msg?: string }) : undefined;
|
||
const route = normalizeRoute(config?.url);
|
||
recordDiagnosticEvent({
|
||
level: "error",
|
||
category: "request",
|
||
code: body?.code !== undefined ? String(body.code) : response?.status ? "HTTP_" + response.status : "NETWORK_ERROR",
|
||
message: body?.msg || String(config?.method || "GET").toUpperCase() + " " + route + " 请求失败",
|
||
route,
|
||
durationMs: meta ? Math.max(0, Math.round(performance.now() - meta.startedAt)) : undefined,
|
||
httpStatus: response?.status,
|
||
requestId: response ? readHeader(response.headers, "x-request-id") : undefined,
|
||
traceId: meta?.traceId,
|
||
});
|
||
return Promise.reject(error);
|
||
},
|
||
);
|
||
}
|
||
|
||
export function setDiagnosticUserScope(userId: string) {
|
||
const next = userId.trim();
|
||
if (next === scopedUserId) return;
|
||
const preserveStartupEvents = !scopedUserId && Boolean(next);
|
||
scopedUserId = next;
|
||
if (!preserveStartupEvents) events.length = 0;
|
||
activeTraceId = createDiagnosticId("trace");
|
||
}
|
||
|
||
export function recordDiagnosticEvent(input: DiagnosticEventInput) {
|
||
const event: ClientDiagnosticEvent = {
|
||
id: createDiagnosticId("event"),
|
||
timestamp: input.timestamp || new Date().toISOString(),
|
||
level: input.level,
|
||
category: input.category,
|
||
code: redactClientText(input.code),
|
||
message: redactClientText(input.message) || "未命名诊断事件",
|
||
route: normalizeRoute(input.route || browserLocation()?.pathname || ""),
|
||
durationMs: input.durationMs === undefined ? undefined : clampNumber(input.durationMs, 0, 86_400_000),
|
||
httpStatus: input.httpStatus === undefined ? undefined : clampNumber(input.httpStatus, 0, 599),
|
||
requestId: safeDiagnosticId(input.requestId),
|
||
traceId: safeDiagnosticId(input.traceId || activeTraceId),
|
||
taskId: safeDiagnosticId(input.taskId),
|
||
projectId: safeDiagnosticId(input.projectId),
|
||
canvasId: safeDiagnosticId(input.canvasId),
|
||
nodes_before: input.nodes_before === undefined ? undefined : clampNumber(input.nodes_before, 0, 10_000_000),
|
||
nodes_after: input.nodes_after === undefined ? undefined : clampNumber(input.nodes_after, 0, 10_000_000),
|
||
stack: redactClientText(input.stack, 4000),
|
||
};
|
||
events.push(event);
|
||
if (events.length > maxEvents) events.splice(0, events.length - maxEvents);
|
||
}
|
||
|
||
export function getClientDiagnosticEvents(range?: { from?: Date; to?: Date }) {
|
||
const from = range?.from?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||
const to = range?.to?.getTime() ?? Number.POSITIVE_INFINITY;
|
||
return events
|
||
.filter((event) => {
|
||
const timestamp = Date.parse(event.timestamp);
|
||
return Number.isFinite(timestamp) && timestamp >= from && timestamp <= to;
|
||
})
|
||
.map((event) => ({ ...event }));
|
||
}
|
||
|
||
export function getDiagnosticRuntime() {
|
||
return {
|
||
appVersion: String(import.meta.env.VITE_APP_VERSION || "dev"),
|
||
buildCommit: String(import.meta.env.VITE_BUILD_COMMIT || "unknown"),
|
||
buildTime: String(import.meta.env.VITE_BUILD_TIME || "unknown"),
|
||
browser: typeof navigator !== "undefined" ? navigator.userAgent : "",
|
||
os: typeof navigator !== "undefined" ? navigator.platform : "",
|
||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||
};
|
||
}
|
||
|
||
export function getDiagnosticTraceId() {
|
||
return activeTraceId;
|
||
}
|
||
|
||
function setTraceHeader(config: AxiosRequestConfig, traceId: string) {
|
||
if (!config.headers) config.headers = {};
|
||
if (typeof config.headers.set === "function") config.headers.set("X-Canvas-Trace-ID", traceId);
|
||
else config.headers["X-Canvas-Trace-ID"] = traceId;
|
||
}
|
||
|
||
function readHeader(headers: unknown, key: string) {
|
||
if (!headers || typeof headers !== "object") return undefined;
|
||
const candidate = headers as { get?: (name: string) => string | null; [name: string]: unknown };
|
||
if (typeof candidate.get === "function") return candidate.get(key) || undefined;
|
||
const value = candidate[key] ?? candidate[key.toLowerCase()];
|
||
return typeof value === "string" ? value : undefined;
|
||
}
|
||
|
||
// 诊断只在真实浏览器里有地址。测试与 SSR 环境里 `window` 可能存在但 `window.location` 不存在
|
||
// (bun test 就是这样),所以两个都要探测,不能只看 typeof window。
|
||
function browserLocation(): Location | undefined {
|
||
return typeof window !== "undefined" && window.location ? window.location : undefined;
|
||
}
|
||
|
||
function normalizeRoute(value?: string) {
|
||
const raw = String(value || "").trim();
|
||
if (!raw) return browserLocation()?.pathname || "/";
|
||
try {
|
||
const parsed = new URL(raw, browserLocation()?.origin || "http://localhost");
|
||
return parsed.pathname || "/";
|
||
} catch {
|
||
return raw.split(/[?#]/, 1)[0] || "/";
|
||
}
|
||
}
|
||
|
||
function safeDiagnosticId(value?: string) {
|
||
const normalized = String(value || "").trim();
|
||
return /^[A-Za-z0-9._:-]{1,96}$/.test(normalized) ? normalized : undefined;
|
||
}
|
||
|
||
function redactClientText(value?: string, maxLength = 1200) {
|
||
let text = String(value || "").trim();
|
||
if (!text) return "";
|
||
text = text.replace(/(authorization|cookie|set-cookie|api[_-]?key|secret[_-]?key|password|token)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]");
|
||
text = text.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => {
|
||
try {
|
||
const parsed = new URL(url);
|
||
parsed.search = "";
|
||
parsed.hash = "";
|
||
return parsed.toString();
|
||
} catch {
|
||
return url.split(/[?#]/, 1)[0];
|
||
}
|
||
});
|
||
return text.slice(0, maxLength);
|
||
}
|
||
|
||
function clampNumber(value: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, Math.round(Number.isFinite(value) ? value : min)));
|
||
}
|
||
|
||
function createDiagnosticId(prefix: string) {
|
||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return prefix + "-" + crypto.randomUUID().replaceAll("-", "").slice(0, 24);
|
||
return prefix + "-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 12);
|
||
}
|