422c8931d6
实现: 在rust内存里维护错误事件队列, webview调用tauri command传入, 后台任务agent工具等直接插入 把rust , webview console的日志统一写到AppData文件夹下的(滚动保存的)日志文件. rust对传入的错误进行筛选,脱敏, 防抖,后通知前端提醒用户. 用户提醒是一个不阻塞的小UI, 展开后可以选择错误上报, 可以附加文字描述 上传时附带最近日志, 错误堆栈等信息 元数据存在数据库, 考虑到字符串信息很难查询, 所以在api server打包成zip存在OSS. 管理页面新增错误报告的查看页面     --------- 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>
263 lines
7.7 KiB
TypeScript
263 lines
7.7 KiB
TypeScript
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();
|
||
}
|