Files
Genarrative/apps/ai-game-creator-shell/src/services/errorReporting.ts
T
k88936 422c8931d6
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m22s
Project CI / Backend tests (push) Successful in 5m53s
Project CI / Native shell tests (push) Successful in 16m45s
Feat/AGC错误报告 (#240)
实现:
在rust内存里维护错误事件队列, webview调用tauri command传入, 后台任务agent工具等直接插入
把rust , webview console的日志统一写到AppData文件夹下的(滚动保存的)日志文件.
rust对传入的错误进行筛选,脱敏, 防抖,后通知前端提醒用户.
用户提醒是一个不阻塞的小UI, 展开后可以选择错误上报, 可以附加文字描述
上传时附带最近日志, 错误堆栈等信息

元数据存在数据库, 考虑到字符串信息很难查询, 所以在api server打包成zip存在OSS.
管理页面新增错误报告的查看页面

![shotmd-1788328504.jpg](/attachments/641b43c8-deed-47d8-8c78-36adb9a2548f)
![shotmd-1788328495.jpg](/attachments/33c6ada3-dd4d-44b6-9859-f18f944eb653)
![shotmd-1788328222.jpg](/attachments/59267df9-f624-4e47-bb9b-b69d77f595c4)
![shotmd-1788328230.jpg](/attachments/63ea6a86-9a51-412b-a98c-dce454bcc968)

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/240
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-03 10:02:05 +08:00

263 lines
7.7 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;
}
}
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();
}