Files
Genarrative/apps/ai-game-creator-shell/src/services/errorReporting.ts
T
suzmii 87968e3be3 布局落盘 / 读盘失败不再吞掉原因:脱敏后留一行日志
- 两处 .catch(() => {…}) 没有 message 参数,失败原因被整个吞掉:用户只看到「布局保存失败」「布局读取失败」,排障端也拿不到为什么(revision 越界 / IPC 拒绝 / 权限);
- 写回失败(update_local_project_resource_canvas_layout 的 .catch)与读盘失败(read_local_project_resource_canvas_layout 的 .catch)都补上 error 参数,并落一行 console.warn;
- 新增 describeResourceCanvasLayoutFailure:把 error.message 压成一行、截断 400 字,并复用 errorReporting 的 normalizeDiagnosticText 脱敏(路径 / token / URL / 长 id 打码);
- errorReporting 的 normalizeDiagnosticText 改为导出:脱敏口径只留一份,业务文件不再各写一套正则;
- 用户可见文案一字不改(LayoutNotice 联合类型与既有四条用例的断言原样保留),原因只进日志;日志由客户端已装的 webview→application.log 桥带进诊断日志;
- 新增两条用例:写回失败必须留下原因(并断言可见文案仍是「布局保存失败,已恢复上次布局」)、IPC 拒绝且原因里带本机绝对路径与 token 时必须脱敏成 <path> 且压成一行;
- 变异验证:把写回那处改回吞掉原因 → 写回用例红灯;把读盘那处改回吞掉原因 → 脱敏用例红灯(各自只红对应一条,既有 24 条全绿)。
2026-09-11 21:00:16 +08:00

269 lines
8.0 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 { invoke } from '@tauri-apps/api/core';
import { getStoredAuthAccessToken } from './clientAuth';
import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp';
import {
ackErrorReports,
getPendingErrorReports,
reportClientError,
subscribeErrorReportUpdates,
} from './errorReportingBridge';
export type ClientErrorEvent = {
eventId: string;
fingerprint: string;
source: string;
message: string;
stack?: string;
occurredAt: string;
count: number;
};
export type DiagnosticLogFile = { name: string; content: string };
type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log';
let fallbackSubmissionSequence = 0;
function createSubmissionId() {
return (
globalThis.crypto?.randomUUID?.() ??
`submission-${Date.now()}-${++fallbackSubmissionSequence}`
);
}
export function shouldCaptureClientError(error: unknown) {
if (!error || typeof error !== 'object') return true;
const candidate = error as { status?: unknown; networkError?: unknown };
if (candidate.networkError === true) return true;
if (typeof candidate.status === 'number') {
return candidate.status === 408 || candidate.status >= 500;
}
return true;
}
export async function invokeDiagnostic<T>(
invokeFn: (command: string, args?: Record<string, unknown>) => Promise<T>,
command: string,
args?: Record<string, unknown>,
) {
try {
return await invokeFn(command, args);
} catch (error) {
void captureClientError(error, { source: 'tauri', action: command });
throw error;
}
}
/**
* 诊断文本脱敏:抹掉授权头、Bearer、api key / token、URL 与本地绝对路径、长十六进制 id。
*
* 交互层的失败提示要保留原因时就复用它,别在业务文件里另写一套正则 ——
* 脱敏口径必须只有一份,否则"某条路径漏了"会随调用点漂移。
*/
export function normalizeDiagnosticText(value: string) {
return value
.replace(
/authorization\s*:\s*(?:bearer\s+)?\S+/giu,
'authorization: [REDACTED]',
)
.replace(/bearer\s+\S+/giu, 'Bearer [REDACTED]')
.replace(/(?:api[_-]?key|token)\s*[=:]\s*\S+/giu, '[REDACTED]')
.replace(/https?:\/\/\S+/giu, '<url>')
.replace(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '<path>')
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>');
}
export async function captureClientError(
error: unknown,
context: { source?: string; action?: string; page?: string } = {},
) {
const errorValue = error instanceof Error ? error : new Error(String(error));
const message = errorValue.message || '未知客户端错误';
const stack = errorValue.stack ? errorValue.stack.slice(0, 8_000) : undefined;
return reportClientError({
source: context.source ?? 'client',
message,
stack,
action: context.action,
page: context.page,
}).catch(() => undefined);
}
export function captureAgentRuntimeError(error: unknown, agentId: string) {
return captureClientError(error, {
source: 'agent-runtime',
action: 'agent-runtime',
page: agentId,
});
}
function formatConsoleArgument(value: unknown) {
if (value instanceof Error) return value.stack || value.message;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function appendWebviewLog(level: WebviewLogLevel, values: unknown[]) {
const message = normalizeDiagnosticText(
values.map(formatConsoleArgument).join(' ').slice(0, 8_000),
);
void invoke('append_application_log', {
level,
source: 'webview',
message,
}).catch(() => {
// 浏览器预览或 Tauri 尚未初始化时不阻断主流程。
});
}
/** 将 WebView console 输出镜像到 Rust 的普通文本 application.log。 */
export function installWebviewLogBridge() {
const marker = '__agcWebviewLogBridgeInstalled';
const target = globalThis as typeof globalThis & { [marker]?: boolean };
const targetConsole = globalThis.console;
if (target[marker] || !targetConsole) return () => {};
target[marker] = true;
const levels: WebviewLogLevel[] = ['debug', 'info', 'warn', 'error', 'log'];
const originals = new Map<WebviewLogLevel, (...args: unknown[]) => void>();
for (const level of levels) {
const original = targetConsole[level].bind(targetConsole) as (
...args: unknown[]
) => void;
originals.set(level, original);
targetConsole[level] = (...args: unknown[]) => {
original(...args);
appendWebviewLog(level, args);
};
}
return () => {
for (const level of levels) {
const original = originals.get(level);
if (original) targetConsole[level] = original;
}
delete target[marker];
};
}
export function getPendingClientErrorEvents() {
return getPendingErrorReports();
}
export function subscribeClientErrorEvents(listener: () => void) {
let disposed = false;
let unlisten: (() => void) | undefined;
void subscribeErrorReportUpdates(() => {
if (!disposed) listener();
})
.then((stop) => {
if (disposed) stop();
else unlisten = stop;
})
.catch(() => {
// Browser previews and partially initialized native webviews do not have
// an event bridge; snapshots and focus/visibility refreshes remain the
// source of truth in those environments.
});
return () => {
disposed = true;
unlisten?.();
};
}
export async function readApplicationDiagnosticLogs(): Promise<
DiagnosticLogFile[]
> {
return invoke<DiagnosticLogFile[]>('read_diagnostic_logs');
}
export async function submitErrorReportBatch(
payload: {
events: ClientErrorEvent[];
userDescription?: string;
logs: DiagnosticLogFile[];
submissionId?: string;
},
apiBaseUrl = getClientServerBaseUrl(),
) {
const token = getStoredAuthAccessToken();
if (!token) throw new Error('请先登录后再提交错误报告');
const response = await fetchClientHttp(
'/api/error-reports',
{
method: 'POST',
credentials: 'same-origin',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
schemaVersion: 1,
submissionId: payload.submissionId ?? createSubmissionId(),
events: payload.events,
userDescription: payload.userDescription?.trim() || null,
logs: payload.logs,
}),
},
{ serverBaseUrl: apiBaseUrl },
);
if (!response.ok) {
throw new Error(`错误报告提交失败(${response.status})`);
}
const result = (await response.json()) as { data?: unknown };
return result.data ?? result;
}
const stableSubmissionIds = new Map<string, string>();
function errorEventKey(events: ClientErrorEvent[]) {
return events
.map((event) => event.eventId)
.sort()
.join('\u0000');
}
export function getStableErrorReportSubmissionId(events: ClientErrorEvent[]) {
const key = errorEventKey(events);
const existing = stableSubmissionIds.get(key);
if (existing) return existing;
const submissionId = createSubmissionId();
stableSubmissionIds.set(key, submissionId);
return submissionId;
}
export async function markClientErrorEventsSubmitted(
events: ClientErrorEvent[],
) {
await ackErrorReports(events.map((event) => event.eventId));
stableSubmissionIds.delete(errorEventKey(events));
}
export async function ackClientErrorEventsWithRetry(
events: ClientErrorEvent[],
) {
for (const [attempt, delay] of [0, 500, 1_000, 2_000].entries()) {
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
try {
await markClientErrorEventsSubmitted(events);
return;
} catch (error) {
console.warn(
`[error-report] 本地 ack 第 ${attempt + 1} 次失败,将继续重试`,
error,
);
}
}
console.warn('[error-report] 本地 ack 重试耗尽,事件仍保留在本地队列');
}
/** 仅供单元测试隔离进程内错误池;生产流程不调用。 */
export function resetClientErrorEventsForTests() {
// Rust 队列按进程生命周期管理;测试通过 fake bridge 重建进程内状态。
stableSubmissionIds.clear();
}