修复错误报告提交与本地确认解耦
服务端报告成功后立即反馈成功,不再让本地队列 ack 失败覆盖提交结果。 为同一批事件复用稳定 submissionId,并对本地 ack 增加有限重试和日志记录。 补充客户端回归测试与错误报告链路文档。
This commit is contained in:
@@ -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 公开路径均拒绝该前缀。归档内同名日志会自动加数字后缀,读取本机诊断日志时拒绝符号链接/非普通文件。
|
||||
|
||||
Reference in New Issue
Block a user