修复错误报告提交与本地确认解耦

服务端报告成功后立即反馈成功,不再让本地队列 ack 失败覆盖提交结果。

为同一批事件复用稳定 submissionId,并对本地 ack 增加有限重试和日志记录。

补充客户端回归测试与错误报告链路文档。
This commit is contained in:
2026-09-02 14:20:34 +08:00
parent 24ab773283
commit 49e872a64d
5 changed files with 96 additions and 7 deletions
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import {
ackClientErrorEventsWithRetry,
type ClientErrorEvent,
type DiagnosticLogFile,
markClientErrorEventsSubmitted,
getStableErrorReportSubmissionId,
readApplicationDiagnosticLogs,
submitErrorReportBatch,
} from '../../services/errorReporting';
@@ -60,14 +61,16 @@ export function ErrorReportDialog({
setBusy(true);
setStatus('正在提交…');
try {
const submissionId = getStableErrorReportSubmissionId(selectedEvents);
await submitErrorReportBatch({
events: selectedEvents,
logs: includeLogs ? logs : [],
userDescription: description,
submissionId,
});
await markClientErrorEventsSubmitted(selectedEvents);
setStatus('已提交,感谢你的反馈');
window.setTimeout(onClose, 700);
void ackClientErrorEventsWithRetry(selectedEvents);
} catch (error) {
setStatus(
error instanceof Error ? error.message : '提交失败,请稍后再试',
@@ -23,6 +23,15 @@ 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 };
@@ -174,6 +183,7 @@ export async function submitErrorReportBatch(
events: ClientErrorEvent[];
userDescription?: string;
logs: DiagnosticLogFile[];
submissionId?: string;
},
apiBaseUrl = getClientServerBaseUrl(),
) {
@@ -191,8 +201,7 @@ export async function submitErrorReportBatch(
},
body: JSON.stringify({
schemaVersion: 1,
submissionId:
globalThis.crypto?.randomUUID?.() ?? `submission-${Date.now()}`,
submissionId: payload.submissionId ?? createSubmissionId(),
events: payload.events,
userDescription: payload.userDescription?.trim() || null,
logs: payload.logs,
@@ -207,11 +216,51 @@ export async function submitErrorReportBatch(
return result.data ?? result;
}
export function markClientErrorEventsSubmitted(events: ClientErrorEvent[]) {
return ackErrorReports(events.map((event) => event.eventId));
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();
}
@@ -81,9 +81,11 @@ import { invoke } from '@tauri-apps/api/core';
import { fetchClientHttp } from '../src/services/clientHttp';
import {
ackClientErrorEventsWithRetry,
captureAgentRuntimeError,
captureClientError,
getPendingClientErrorEvents,
getStableErrorReportSubmissionId,
installWebviewLogBridge,
markClientErrorEventsSubmitted,
resetClientErrorEventsForTests,
@@ -153,6 +155,40 @@ describe('客户端错误报告池', () => {
expect(await getPendingClientErrorEvents()).toEqual([second]);
});
it('同一批事件复用稳定 submissionId', async () => {
const event = await captureClientError(new Error('可重试错误'), {
source: 'test',
});
const firstId = getStableErrorReportSubmissionId([event]);
const secondId = getStableErrorReportSubmissionId([event]);
expect(secondId).toBe(firstId);
});
it('本地 ack 失败时自动重试且不向调用方抛错', async () => {
vi.useFakeTimers();
const event = await captureClientError(new Error('ack 重试错误'), {
source: 'test',
});
vi.mocked(invoke)
.mockRejectedValueOnce(new Error('暂时不可用'))
.mockResolvedValueOnce(undefined);
const pending = ackClientErrorEventsWithRetry([event]);
await vi.advanceTimersByTimeAsync(500);
await pending;
expect(invoke).toHaveBeenCalledWith('ack_error_reports', {
eventIds: [event.eventId],
});
expect(
vi
.mocked(invoke)
.mock.calls.filter(([command]) => command === 'ack_error_reports'),
).toHaveLength(2);
vi.useRealTimers();
});
it('未登录时拒绝提交且不发请求', async () => {
const auth = await import('../src/services/clientAuth');
vi.mocked(auth.getStoredAuthAccessToken).mockReturnValue('');
@@ -7907,3 +7907,4 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 2026-09-01 追加:错误报告不落本地文件;请求内存构建 ZIP 后直接上传 OSS,成功后写入 SpacetimeDB `error_report` 元数据。OSS key 固定为 `agc/error-reports/v1/{batchId}.zip`,不含日期;同一用户 `userId + submissionId` 幂等。管理员查询 DB,详情/下载按 object key 读取 OSS;每日清理先删 OSS,再删 DB,失败留待下次重试。
- 2026-09-01 review minor 修复:错误报告每日清理改为完整分页扫描,DB 删除失败显式返回以便下周期重试;OSS 读取按 `Content-Length` 与流式累计执行大小上限;管理员归档解析限制解压后 `events.jsonl` 为 24 MiB / 100 条事件。
- 2026-09-02 review breaking 项落地:错误报告 create/update procedure 删除 `user_id``now_micros` 输入字段,分别改用 `ctx.sender()``ctx.timestamp`;首个 fingerprint/source 强制 512 字符上限,备注超过 2,000 字显式拒绝;内部 OSS 对象键收紧为 `agc/error-reports/v1/`,凭据脱敏覆盖空白/分隔符变体。发布需同步 module、spacetime-client bindings 与 api-server。
- 2026-09-02 客户端错误报告提交成功与本地队列 ack 解耦:POST 成功即显示提交成功并关闭,ack 使用有限重试且失败只记录日志;同一批 eventId 在进程内复用稳定 submissionId,避免 ack 失败后重新提交产生重复报告。
@@ -19,7 +19,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
## HTTP 与存储
- 登录态客户端使用 `POST /api/error-reports`,请求 DTO 位于 `shared-contracts::error_reports`
- api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与凭据脱敏,归档使用 `events.jsonl`(每行一个事件)。结构化事件只保存在当前进程内,用户提交时才生成 `events.jsonl`,不在磁盘单独持久化。submissionId 提供重放幂等;SpacetimeDB procedure 不接受调用方 `user_id``now_micros`,分别使用 `ctx.sender()``ctx.timestamp`
- api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与凭据脱敏,归档使用 `events.jsonl`(每行一个事件)。结构化事件只保存在当前进程内,用户提交时才生成 `events.jsonl`,不在磁盘单独持久化。submissionId 提供重放幂等;客户端对同一批事件复用稳定 submissionId,服务端提交成功后立即反馈成功,本地事件 ack 独立重试,不因 ack 失败误报提交失败;SpacetimeDB procedure 不接受调用方 `user_id``now_micros`,分别使用 `ctx.sender()``ctx.timestamp`
- 归档构建只在请求生命周期内使用受 20 MiB 上限约束的内存 `Vec<u8>`,随后直接 PUT 到私有 OSS;服务端不写本地报告文件,也不保留本地索引。OSS 上传失败不写入数据库,调用方可稍后重新提交。
- 归档对象使用固定私有 OSS key:`agc/error-reports/v1/{batchId}.zip`;key 只由报告 UUID 决定,不包含时间戳。上传成功后才写入 SpacetimeDB `error_report` 元数据表;`userId + submissionId` 由唯一幂等键保证重放返回已有记录。完整事件、说明和日志只存在 OSS ZIP。
- `agc` 是服务端专用私有前缀;公共直传票据、通用 object-key 规范化和 legacy 公开路径均拒绝该前缀。归档内同名日志会自动加数字后缀,读取本机诊断日志时拒绝符号链接/非普通文件。