Files
ttstd-infinite-canvas/web/src/services/diagnostics/client-diagnostics.ts
ddcat666 5e69106d8f fix(canvas): 画布同步 - 版本冲突先核对并合并,本地保存与云端状态分开展示
本地持久化记录保存时间和日志令牌,同步面板区分本地与云端,不再用提示打断编辑。冲突时先尝试合并,无法合并才保留草稿并暂停自动提交;未登录时生成结果留在本地,登录后再继续上传。
2026-10-09 17:17:38 +08:00

233 lines
10 KiB
TypeScript
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 { 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);
}