修复客户端错误报告采集与后台交互

统一错误报告打开事件常量并补充快捷入口断言

修复后台详情竞态、下载兼容性、状态选项和错误提示

加强客户端脱敏、指纹降级并过滤预期鉴权错误
This commit is contained in:
2026-09-01 11:43:44 +08:00
parent 734ae175b0
commit 6f810b6d01
8 changed files with 102 additions and 39 deletions
+5 -3
View File
@@ -23,6 +23,8 @@ import type {
AdminEditorShowcaseListQuery,
AdminEditorShowcaseListResponse,
AdminEditorShowcaseReviewRequest,
AdminErrorReportDetail,
AdminErrorReportListResponse,
AdminFeatureGateConfigResponse,
AdminLoginResponse,
AdminMeResponse,
@@ -280,14 +282,14 @@ export function listAdminErrorReports(
limit?: number;
} = {},
) {
return request<import('./adminApiTypes').AdminErrorReportListResponse>(
return request<AdminErrorReportListResponse>(
`/admin/api/error-reports${buildErrorReportQueryString(query)}`,
{ token },
);
}
export function getAdminErrorReport(token: string, batchId: string) {
return request<import('./adminApiTypes').AdminErrorReportDetail>(
return request<AdminErrorReportDetail>(
`/admin/api/error-reports/${encodeURIComponent(batchId)}`,
{ token },
);
@@ -298,7 +300,7 @@ export function updateAdminErrorReport(
batchId: string,
payload: { status: string; note?: string },
) {
return request<import('./adminApiTypes').AdminErrorReportDetail>(
return request<AdminErrorReportDetail>(
`/admin/api/error-reports/${encodeURIComponent(batchId)}`,
{ method: 'PATCH', token, body: payload },
);
-1
View File
@@ -112,7 +112,6 @@ export interface AdminErrorReportEntry {
ossObjectKey?: string;
archiveSha256?: string;
uploadStatus?: 'uploading' | 'ready' | 'failed';
reviewStatus?: 'new' | 'in-progress' | 'resolved';
}
export interface AdminErrorReportListResponse {
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
downloadAdminErrorReport,
@@ -15,14 +15,18 @@ import type {
type Props = { token: string; onUnauthorized: (message?: string) => void };
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
const [selected, setSelected] = useState<AdminErrorReportDetail | null>(null);
const [status, setStatus] = useState('');
const [busy, setBusy] = useState(false);
const [filterStatus, setFilterStatus] = useState('');
const openReportRequestId = useRef(0);
const load = useCallback(async () => {
setStatus('');
try {
const response = await listAdminErrorReports(token, {
status: filterStatus || undefined,
@@ -40,9 +44,13 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
}, [load]);
async function openReport(batchId: string) {
const requestId = ++openReportRequestId.current;
setStatus('');
try {
setSelected(await getAdminErrorReport(token, batchId));
const detail = await getAdminErrorReport(token, batchId);
if (requestId === openReportRequestId.current) setSelected(detail);
} catch (error) {
if (requestId !== openReportRequestId.current) return;
if (isAdminApiError(error) && error.status === 401)
return onUnauthorized();
setStatus(formatAdminApiError(error));
@@ -52,6 +60,7 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
async function saveStatus(nextStatus: string) {
if (!selected || busy) return;
setBusy(true);
setStatus('');
try {
setSelected(
await updateAdminErrorReport(token, selected.batchId, {
@@ -69,13 +78,16 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
}
async function download(batchId: string) {
setStatus('');
try {
const blob = await downloadAdminErrorReport(token, batchId);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${batchId}.zip`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (error) {
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
@@ -97,13 +109,19 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
onChange={(event) => setFilterStatus(event.target.value)}
>
<option value=""></option>
<option value="new">new</option>
<option value="in-progress">in-progress</option>
<option value="resolved">resolved</option>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
</div>
{status ? <p className="admin-error-message">{status}</p> : null}
{status ? (
<p className="admin-alert" role="status">
{status}
</p>
) : null}
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
@@ -160,9 +178,11 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
<option value="new">new</option>
<option value="in-progress">in-progress</option>
<option value="resolved">resolved</option>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
type="button"
@@ -33,6 +33,7 @@ import {
import {
captureClientError,
installWebviewLogBridge,
OPEN_ERROR_REPORT_EVENT,
} from '../services/errorReporting';
import {
beginPlatformSessionClearTransition,
@@ -156,9 +157,9 @@ export function AuthenticatedClient({
});
setErrorReportOpen(true);
};
window.addEventListener('agc-open-error-report', openReport);
window.addEventListener(OPEN_ERROR_REPORT_EVENT, openReport);
return () =>
window.removeEventListener('agc-open-error-report', openReport);
window.removeEventListener(OPEN_ERROR_REPORT_EVENT, openReport);
}, []);
function persistServerSelection() {
@@ -9,6 +9,7 @@ import type {
AgentStatusCard,
ChatMessage,
} from '../../app/types';
import { OPEN_ERROR_REPORT_EVENT } from '../../services/errorReporting';
import {
summarizeAgentReviewState,
summarizeAgentRunBudget,
@@ -514,7 +515,7 @@ export function handleProjectSummaryChatCommand({
},
]);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('agc-open-error-report'));
window.dispatchEvent(new CustomEvent(OPEN_ERROR_REPORT_EVENT));
}
return true;
}
@@ -89,12 +89,15 @@ export async function requestClientApi<T>(
);
}
if (!response.ok) {
void captureClientError(
new ClientAuthRequestError(`HTTP ${response.status}`, {
status: response.status,
}),
{ source: 'api', action: url.split('?')[0] },
);
const shouldCapture = response.status >= 500 || response.status === 408;
if (shouldCapture) {
void captureClientError(
new ClientAuthRequestError(`HTTP ${response.status}`, {
status: response.status,
}),
{ source: 'api', action: url.split('?')[0] },
);
}
throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage),
{ status: response.status },
@@ -138,12 +141,15 @@ export async function requestClientApiBytes(
);
}
if (!response.ok) {
void captureClientError(
new ClientAuthRequestError(`HTTP ${response.status}`, {
status: response.status,
}),
{ source: 'api', action: url.split('?')[0] },
);
const shouldCapture = response.status >= 500 || response.status === 408;
if (shouldCapture) {
void captureClientError(
new ClientAuthRequestError(`HTTP ${response.status}`, {
status: response.status,
}),
{ source: 'api', action: url.split('?')[0] },
);
}
throw new ClientAuthRequestError(
await readApiErrorMessage(response, fallbackMessage),
{ status: response.status },
@@ -17,6 +17,8 @@ export type DiagnosticLogFile = { name: string; content: string };
type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log';
export const OPEN_ERROR_REPORT_EVENT = 'agc-open-error-report';
const pending = new Map<string, ClientErrorEvent>();
const listeners = new Set<() => void>();
const MAX_EVENTS = 100;
@@ -26,13 +28,24 @@ function notify() {
}
function normalizeMessage(value: string) {
return (
normalizeDiagnosticText(value)
.replace(/[\r\n]+/gu, ' ')
// 保留 HTTP 状态码、短计数等有诊断价值的小数字;仅折叠较长的可识别数字。
.replace(/\d{5,}/gu, '<n>')
.trim()
.slice(0, 512)
);
}
function normalizeDiagnosticText(value: string) {
return value
.replace(/authorization\s*:\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>')
.replace(/\d{2,}/gu, '<n>')
.trim()
.slice(0, 512);
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>');
}
async function sha256(value: string) {
@@ -54,13 +67,26 @@ export async function captureClientError(
) {
const errorValue = error instanceof Error ? error : new Error(String(error));
const message = normalizeMessage(errorValue.message || '未知客户端错误');
const stack = errorValue.stack?.slice(0, 8_000);
const stack = errorValue.stack
? normalizeDiagnosticText(errorValue.stack).slice(0, 8_000)
: undefined;
// 只用栈的首行参与指纹,避免同一错误因调用位置行号变化而无法合并。
const fingerprint = await sha256(
[context.source ?? 'client', message, stack?.split('\n')[0]]
.filter(Boolean)
.join('|'),
);
const fingerprintInput = [
context.source ?? 'client',
context.action,
context.page,
message,
stack?.split('\n')[0],
]
.filter(Boolean)
.join('|');
let fingerprint: string;
try {
fingerprint = await sha256(fingerprintInput);
} catch {
// 错误采集绝不能反过来制造 unhandledrejection;降级为稳定的可读键。
fingerprint = fingerprintInput;
}
const existing = pending.get(fingerprint);
const now = new Date().toISOString();
if (existing) {
@@ -1,3 +1,4 @@
import { OPEN_ERROR_REPORT_EVENT } from '../../../../src/services/errorReporting';
import { expect, fireEvent, screen, submitChat, within } from '../../harness';
import type { PreviewShortcutInvoke } from './invoke-mock';
@@ -647,10 +648,17 @@ export async function assertPlaytestAndReleaseShortcutFlow(
([command]) => command === 'control_agent_run',
).length,
};
let openedErrorReport = false;
const onErrorReportOpen = () => {
openedErrorReport = true;
};
window.addEventListener(OPEN_ERROR_REPORT_EVENT, onErrorReportOpen);
submitChat('/bug-report');
expect(
await screen.findByText('已打开“报告问题”面板,请填写遇到的问题。'),
).toBeTruthy();
).not.toBeNull();
expect(openedErrorReport).toBe(true);
window.removeEventListener(OPEN_ERROR_REPORT_EVENT, onErrorReportOpen);
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',