Files
Genarrative/apps/ai-game-creator-shell/src/services/errorReporting.ts
T
kdletters df39d5a16f
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
修复客户端渠道与错误报告展示
客户端 debug 保留服务器选择,正式包按发布渠道连接服务

修复错误报告时间解析与超时路由脱敏

重做后台错误详情面板并补齐定向测试

同步渠道、诊断与后台展示文档
2026-09-21 01:52:35 +08:00

288 lines
8.5 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。
*
* 交互层的失败提示要保留原因时就复用它,别在业务文件里另写一套正则 ——
* 脱敏口径必须只有一份,否则"某条路径漏了"会随调用点漂移。
*/
function redactDiagnosticUrl(value: string) {
return value.replace(/https?:\/\/[^\s"'<>]+/giu, (rawUrl) => {
let parsed: URL;
try {
parsed = new URL(rawUrl.replace(/[),.;!?]+$/u, ''));
} catch {
return '<url>';
}
const pathname = parsed.pathname || '/';
if (
pathname.startsWith('/api/') ||
pathname.startsWith('/v1/') ||
pathname.startsWith('/admin/')
) {
return `<origin>${pathname}`;
}
return '<url>';
});
}
export function normalizeDiagnosticText(value: string) {
return redactDiagnosticUrl(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(/[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();
}