重构 AGC 错误报告为 Rust 队列

新增 Rust 内存错误队列、脱敏、通知聚合和 Tauri commands

前端改用 bridge,拆分通知与报告弹窗 UI

补充 Vitest、Rust 单测及错误报告技术方案
This commit is contained in:
2026-09-01 19:52:27 +08:00
parent 2f0005ecad
commit 584eabd1fd
16 changed files with 651 additions and 180 deletions
+1
View File
@@ -1725,6 +1725,7 @@ dependencies = [
"platform-agent",
"platform-llm",
"portable-pty",
"regex",
"reqwest 0.12.28",
"schemars 1.2.1",
"serde",
@@ -43,6 +43,7 @@ platform-llm = { path = "../../../server-rs/crates/platform-llm" }
platform-agent = { path = "../../../server-rs/crates/platform-agent" }
portable-pty = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
regex = "1"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
@@ -1671,7 +1671,7 @@ pub(crate) fn decide_game_creator_plan_gdd(
enforce_project_permission_policy(&root, "conversation.write")?;
enforce_project_permission_policy(&root, "agent.run_status")?;
enforce_project_permission_policy(&root, "agent.resume")?;
let mut result = decide_plan_gdd_at(
let mut result = match decide_plan_gdd_at(
&root,
&DecidePlanGddInputV1 {
gdd_id,
@@ -1683,10 +1683,30 @@ pub(crate) fn decide_game_creator_plan_gdd(
action,
comment,
},
)
.map_err(|error| error.to_string())?;
) {
Ok(result) => result,
Err(error) => {
let detail = error.to_string();
crate::error_report::report_diagnostic_error(
"tauri",
&detail,
None,
Some("decide_game_creator_plan_gdd"),
None,
);
return Err(detail);
}
};
if !result.recovery_pending {
if wake_pending_game_creator_agent_background_tasks_at(&root).is_err() {
if let Err(error) = wake_pending_game_creator_agent_background_tasks_at(&root) {
let detail = error.to_string();
crate::error_report::report_diagnostic_error(
"agent",
&detail,
None,
Some("wake_pending_game_creator_agent_background_tasks"),
None,
);
// The receipt is already the user-decision linearization point;
// surface a recoverable projection state instead of turning a
// durable approval into a false command failure.
@@ -0,0 +1,38 @@
use tauri::command;
use super::queue::{ack, mark_notified, report_diagnostic_error, snapshot, ErrorReportEvent};
#[command]
pub fn report_client_error(
source: String,
message: String,
stack: Option<String>,
action: Option<String>,
page: Option<String>,
) -> Result<ErrorReportEvent, String> {
report_diagnostic_error(
&source,
&message,
stack.as_deref(),
action.as_deref(),
page.as_deref(),
)
.ok_or_else(|| "错误消息不能为空".to_string())
}
#[command]
pub fn get_pending_error_reports() -> Vec<ErrorReportEvent> {
snapshot()
}
#[command]
pub fn mark_error_reports_notified(event_ids: Vec<String>) -> Result<(), String> {
mark_notified(&event_ids);
Ok(())
}
#[command]
pub fn ack_error_reports(event_ids: Vec<String>) -> Result<(), String> {
ack(&event_ids);
Ok(())
}
@@ -0,0 +1,10 @@
mod commands;
mod notifications;
mod queue;
mod sanitize;
pub use commands::{
ack_error_reports, get_pending_error_reports, mark_error_reports_notified, report_client_error,
};
pub use notifications::initialize_notifications;
pub use queue::report_diagnostic_error;
@@ -0,0 +1,58 @@
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use super::queue::{mark_notified, snapshot, unnotified_count};
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
static TIMER_ACTIVE: OnceLock<Mutex<bool>> = OnceLock::new();
pub fn initialize_notifications(app: &AppHandle) {
let _ = APP_HANDLE.set(app.clone());
let _ = TIMER_ACTIVE.set(Mutex::new(false));
}
pub(crate) fn schedule_notification() {
let Some(handle) = APP_HANDLE.get().cloned() else {
return;
};
let active = TIMER_ACTIVE.get_or_init(|| Mutex::new(false));
{
let mut value = active
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if *value {
return;
}
*value = true;
}
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
let events = snapshot();
let ids: Vec<String> = events
.iter()
.filter(|event| !event.notified)
.map(|event| event.event_id.clone())
.collect();
if !ids.is_empty() {
mark_notified(&ids);
let _ = handle.emit(
"error-report-updated",
serde_json::json!({
"generation": events.len(),
"newCount": ids.len(),
"eventIds": ids,
}),
);
}
if let Some(active) = TIMER_ACTIVE.get() {
*active
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = false;
}
if unnotified_count() > 0 {
schedule_notification();
}
});
}
@@ -0,0 +1,219 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use super::notifications::schedule_notification;
use super::sanitize::{fingerprint, sanitize};
const MAX_EVENTS: usize = 100;
const MAX_SOURCE_CHARS: usize = 128;
const MAX_MESSAGE_CHARS: usize = 512;
const MAX_STACK_CHARS: usize = 8_000;
const MAX_ACTION_CHARS: usize = 128;
const MAX_PAGE_CHARS: usize = 128;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ErrorReportEvent {
pub event_id: String,
pub fingerprint: String,
pub source: String,
pub message: String,
pub stack: Option<String>,
pub occurred_at: String,
pub last_occurred_at: String,
pub count: u32,
pub notified: bool,
}
#[derive(Default)]
struct Queue {
events: HashMap<String, ErrorReportEvent>,
order: VecDeque<String>,
sequence: u64,
}
static QUEUE: OnceLock<Mutex<Queue>> = OnceLock::new();
fn queue() -> &'static Mutex<Queue> {
QUEUE.get_or_init(|| Mutex::new(Queue::default()))
}
fn now() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{seconds}")
}
pub fn report_diagnostic_error(
source: &str,
message: &str,
stack: Option<&str>,
action: Option<&str>,
page: Option<&str>,
) -> Option<ErrorReportEvent> {
let raw_call_site = stack.and_then(|value| {
value.lines().find_map(|line| {
let trimmed = line.trim();
trimmed.strip_prefix("at ").map(|site| {
site.trim_end_matches(|character: char| {
character.is_ascii_digit()
|| character == ':'
|| character == ')'
|| character == '('
})
.trim()
.to_string()
})
})
});
let source = sanitize(source, MAX_SOURCE_CHARS);
let message = sanitize(message, MAX_MESSAGE_CHARS);
let stack = stack.map(|value| sanitize(value, MAX_STACK_CHARS));
let action = action.map(|value| sanitize(value, MAX_ACTION_CHARS));
let page = page.map(|value| sanitize(value, MAX_PAGE_CHARS));
if message.trim().is_empty() {
return None;
}
let fingerprint = fingerprint(&[
&source,
action.as_deref().unwrap_or_default(),
page.as_deref().unwrap_or_default(),
&message,
raw_call_site.as_deref().unwrap_or_default(),
]);
let timestamp = now();
let (should_schedule, event) = {
let mut state = queue()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(existing) = state.events.get_mut(&fingerprint) {
existing.count = existing.count.saturating_add(1);
existing.last_occurred_at = timestamp;
(false, existing.clone())
} else {
state.sequence = state.sequence.saturating_add(1);
let event_id = format!("rust-error-{}", state.sequence);
if state.order.len() >= MAX_EVENTS {
if let Some(oldest) = state.order.pop_front() {
state.events.remove(&oldest);
}
}
state.order.push_back(fingerprint.clone());
let event = ErrorReportEvent {
event_id,
fingerprint,
source,
message,
stack,
occurred_at: timestamp.clone(),
last_occurred_at: timestamp,
count: 1,
notified: false,
};
state
.events
.insert(event.fingerprint.clone(), event.clone());
(true, event)
}
};
if should_schedule {
schedule_notification();
}
Some(event)
}
pub(crate) fn snapshot() -> Vec<ErrorReportEvent> {
let state = queue()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state
.order
.iter()
.filter_map(|fingerprint| state.events.get(fingerprint).cloned())
.collect()
}
pub(crate) fn mark_notified(event_ids: &[String]) {
let mut state = queue()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
for event in state.events.values_mut() {
if event_ids.iter().any(|id| id == &event.event_id) {
event.notified = true;
}
}
}
pub(crate) fn ack(event_ids: &[String]) {
let mut state = queue()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
for event_id in event_ids {
let Some(fingerprint) = state.events.iter().find_map(|(fingerprint, event)| {
(event.event_id == *event_id).then_some(fingerprint.clone())
}) else {
continue;
};
state.events.remove(&fingerprint);
state.order.retain(|item| item != &fingerprint);
}
}
pub(crate) fn unnotified_count() -> usize {
let state = queue()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state
.events
.values()
.filter(|event| !event.notified)
.count()
}
#[cfg(test)]
pub(crate) fn reset_for_tests() {
let mut state = queue()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*state = Queue::default();
}
#[cfg(test)]
mod tests {
use super::{report_diagnostic_error, reset_for_tests, snapshot};
#[test]
fn merges_events_by_call_site_without_storing_second_queue() {
reset_for_tests();
let first = report_diagnostic_error(
"test",
"boom",
Some("Error: boom\n at app.ts:10:2"),
None,
None,
)
.expect("first event");
let second = report_diagnostic_error(
"test",
"boom",
Some("Error: boom\n at app.ts:99:7"),
None,
None,
)
.expect("merged event");
assert_eq!(first.event_id, second.event_id);
assert_eq!(snapshot()[0].count, 2);
}
#[test]
fn rejects_empty_messages() {
reset_for_tests();
assert!(report_diagnostic_error("test", "\n", None, None, None).is_none());
assert!(snapshot().is_empty());
}
}
@@ -0,0 +1,82 @@
use sha2::{Digest, Sha256};
fn replace_pattern(value: String, pattern: &str, replacement: &str) -> String {
regex::Regex::new(pattern)
.expect("valid diagnostic sanitization pattern")
.replace_all(&value, replacement)
.into_owned()
}
pub(crate) fn sanitize(value: &str, max_chars: usize) -> String {
let normalized = value
.to_string()
.pipe(|value| {
replace_pattern(
value,
r"(?i)authorization\s*:\s*(?:bearer\s+)?\S+",
"authorization: [REDACTED]",
)
})
.pipe(|value| replace_pattern(value, r"(?i)bearer\s+\S+", "Bearer [REDACTED]"))
.pipe(|value| {
replace_pattern(
value,
r"(?i)(?:api[_-]?key|token)\s*[=:]\s*\S+",
"[REDACTED]",
)
})
.pipe(|value| replace_pattern(value, r"(?i)https?://\S+", "<url>"))
.pipe(|value| {
replace_pattern(
value,
r"(?i)[A-Z]:\\[^\s]+|/(?:Users|home|private|tmp)/[^\s]+",
"<path>",
)
})
.pipe(|value| replace_pattern(value, r"(?i)\b[0-9a-f]{8,}\b", "<id>"));
normalized
.replace(['\r', '\n'], " ")
.chars()
.filter(|character| !character.is_control() || *character == '\t')
.collect::<String>()
.chars()
.take(max_chars)
.collect()
}
trait Pipe: Sized {
fn pipe<T>(self, function: impl FnOnce(Self) -> T) -> T {
function(self)
}
}
impl<T> Pipe for T {}
pub(crate) fn fingerprint(parts: &[&str]) -> String {
let mut hasher = Sha256::new();
for part in parts {
hasher.update(part.as_bytes());
hasher.update([0]);
}
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::sanitize;
#[test]
fn redacts_credentials_urls_paths_and_ids() {
let value = "authorization: Bearer secret token=abc https://example.test/a /home/alice/project deadbeef12";
let sanitized = sanitize(value, 512);
assert!(!sanitized.contains("secret"));
assert!(!sanitized.contains("example.test"));
assert!(!sanitized.contains("/home/alice"));
assert!(!sanitized.contains("deadbeef12"));
}
#[test]
fn removes_newlines_and_bounds_length() {
assert_eq!(sanitize("a\nb\u{0000}c", 3), "a b");
}
}
@@ -21,6 +21,7 @@ use platform_llm::{
};
use reqwest::header;
use serde::{Deserialize, Serialize};
use shared_contracts::error_reports::ErrorReportLogInput;
use shared_contracts::game_creation_app::{
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
@@ -39,7 +40,6 @@ use shared_contracts::game_creation_app::{
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
};
use shared_contracts::error_reports::ErrorReportLogInput;
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
@@ -73,6 +73,7 @@ mod context_menu;
#[cfg(all(debug_assertions, not(test)))]
mod debug;
mod delegation;
pub mod error_report;
mod git_inspect;
mod goal;
mod image_inspect;
@@ -107,6 +108,7 @@ use commands::*;
use config::*;
use context_compaction::*;
use delegation::*;
use error_report::*;
use git_inspect::*;
use goal::*;
use image_inspect::*;
@@ -2105,6 +2107,7 @@ fn main() {
.manage(game_creator_preview_registry())
.manage(ProjectResourcePreviewReadManager::default())
.setup(move |app| {
error_report::initialize_notifications(app.handle());
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.setup.begin");
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin");
@@ -2356,7 +2359,11 @@ fn main() {
get_local_game_project_revision,
get_local_game_manifest,
append_application_log,
read_diagnostic_logs
read_diagnostic_logs,
report_client_error,
get_pending_error_reports,
mark_error_reports_notified,
ack_error_reports
])
.build(tauri_context);
let app = match app {
@@ -9,7 +9,7 @@ import {
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ErrorReportCenter } from '../components/error-report/ErrorReportDialog';
import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice';
import {
clearStoredAuthAccessToken,
getClientAuthErrorMessage,
@@ -570,7 +570,7 @@ export function AuthenticatedClient({
<ClientRuntimeErrorBoundary onLogout={logout}>
{children({ user: authUser, logout })}
</ClientRuntimeErrorBoundary>
<ErrorReportCenter />
<ErrorReportNotice />
</>
);
}
@@ -1,13 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
type ClientErrorEvent,
type DiagnosticLogFile,
getPendingClientErrorEvents,
markClientErrorEventsSubmitted,
readApplicationDiagnosticLogs,
submitErrorReportBatch,
subscribeClientErrorEvents,
} from '../../services/errorReporting';
import { ThemedModal } from '../modal/ThemedModal';
@@ -67,7 +65,7 @@ export function ErrorReportDialog({
logs: includeLogs ? logs : [],
userDescription: description,
});
markClientErrorEventsSubmitted(selectedEvents);
await markClientErrorEventsSubmitted(selectedEvents);
setStatus('已提交,感谢你的反馈');
window.setTimeout(onClose, 700);
} catch (error) {
@@ -185,78 +183,3 @@ export function ErrorReportDialog({
</ThemedModal>
);
}
export function ErrorReportCenter() {
const [pendingEvents, setPendingEvents] = useState<ClientErrorEvent[]>([]);
const [notificationEvents, setNotificationEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
const notifiedFingerprints = useRef(new Set<string>());
const aggregationTimer = useRef<number | null>(null);
const sync = useCallback(() => {
setPendingEvents(getPendingClientErrorEvents());
}, []);
useEffect(() => {
sync();
return subscribeClientErrorEvents(sync);
}, [sync]);
useEffect(() => {
const candidates = pendingEvents.filter(
(event) => !notifiedFingerprints.current.has(event.fingerprint),
);
if (
!candidates.length ||
notificationEvents.length ||
aggregationTimer.current !== null
)
return;
aggregationTimer.current = window.setTimeout(() => {
aggregationTimer.current = null;
const next = getPendingClientErrorEvents();
const newlyNotified = next.filter(
(event) => !notifiedFingerprints.current.has(event.fingerprint),
);
newlyNotified.forEach((event) =>
notifiedFingerprints.current.add(event.fingerprint),
);
if (newlyNotified.length) setNotificationEvents(next);
}, 5000);
return () => {
if (aggregationTimer.current !== null) {
window.clearTimeout(aggregationTimer.current);
aggregationTimer.current = null;
}
};
}, [notificationEvents.length, pendingEvents]);
const ignore = () => setNotificationEvents([]);
const openReport = () => {
setReportEvents(notificationEvents);
setNotificationEvents([]);
};
return (
<>
{notificationEvents.length ? (
<aside className="error-report-notice" role="status" aria-live="polite">
<span> {notificationEvents.length} </span>
<button type="button" onClick={openReport}>
</button>
<button type="button" onClick={ignore} aria-label="忽略">
×
</button>
</aside>
) : null}
<ErrorReportDialog
open={reportEvents.length > 0}
events={reportEvents}
onClose={() => setReportEvents([])}
/>
</>
);
}
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from 'react';
import {
type ClientErrorEvent,
getPendingClientErrorEvents,
subscribeClientErrorEvents,
} from '../../services/errorReporting';
import { ErrorReportDialog } from './ErrorReportDialog';
export function ErrorReportNotice() {
const [notificationEvents, setNotificationEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
const loadEvents = useCallback(async (eventIds?: string[]) => {
try {
const events = await getPendingClientErrorEvents();
if (!eventIds?.length) return;
const ids = new Set(eventIds);
setNotificationEvents(events.filter((event) => ids.has(event.eventId)));
} catch {
// 通知失败不应打断 AGC 主流程。
}
}, []);
useEffect(() => subscribeClientErrorEvents(loadEvents), [loadEvents]);
const openReport = () => {
setReportEvents(notificationEvents);
setNotificationEvents([]);
};
return (
<>
{notificationEvents.length ? (
<aside className="error-report-notice" role="status" aria-live="polite">
<span> {notificationEvents.length} </span>
<button type="button" onClick={openReport}>
</button>
<button
type="button"
onClick={() => setNotificationEvents([])}
aria-label="忽略"
>
×
</button>
</aside>
) : null}
<ErrorReportDialog
open={reportEvents.length > 0}
events={reportEvents}
onClose={() => setReportEvents([])}
/>
</>
);
}
@@ -2,6 +2,12 @@ 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;
@@ -17,10 +23,6 @@ export type DiagnosticLogFile = { name: string; content: string };
type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log';
const pending = new Map<string, ClientErrorEvent>();
const listeners = new Set<() => void>();
const MAX_EVENTS = 100;
export function shouldCaptureClientError(error: unknown) {
if (!error || typeof error !== 'object') return true;
const candidate = error as { status?: unknown; networkError?: unknown };
@@ -44,21 +46,6 @@ export async function invokeDiagnostic<T>(
}
}
function notify() {
listeners.forEach((listener) => listener());
}
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(
@@ -72,73 +59,20 @@ function normalizeDiagnosticText(value: string) {
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>');
}
async function sha256(value: string) {
if (globalThis.crypto?.subtle) {
const digest = await globalThis.crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(value),
);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
return normalizeMessage(value);
}
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 = normalizeMessage(errorValue.message || '未知客户端错误');
const stack = errorValue.stack
? normalizeDiagnosticText(errorValue.stack).slice(0, 8_000)
: undefined;
const callSite = stack
?.split('\n')
.find((line) => /^\s*at\s+/u.test(line))
?.replace(/:\d+(?::\d+)?\)?$/u, '')
.trim();
const fingerprintInput = [
context.source ?? 'client',
context.action,
context.page,
message,
callSite,
]
.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) {
existing.count += 1;
existing.occurredAt = now;
notify();
return existing;
}
const event: ClientErrorEvent = {
eventId: `client-error-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
fingerprint,
const message = errorValue.message || '未知客户端错误';
const stack = errorValue.stack ? errorValue.stack.slice(0, 8_000) : undefined;
return reportClientError({
source: context.source ?? 'client',
message,
stack,
occurredAt: now,
count: 1,
};
if (pending.size >= MAX_EVENTS) {
const oldest = pending.keys().next().value;
if (oldest) pending.delete(oldest);
}
pending.set(fingerprint, event);
notify();
return event;
action: context.action,
page: context.page,
}).catch(() => undefined);
}
function formatConsoleArgument(value: unknown) {
@@ -193,13 +127,23 @@ export function installWebviewLogBridge() {
}
export function getPendingClientErrorEvents() {
return Array.from(pending.values());
return getPendingErrorReports();
}
export function subscribeClientErrorEvents(listener: () => void) {
listeners.add(listener);
export function subscribeClientErrorEvents(
listener: (eventIds: string[]) => void,
) {
let disposed = false;
let unlisten: (() => void) | undefined;
void subscribeErrorReportUpdates((update) => {
if (!disposed) listener(update.eventIds ?? []);
}).then((stop) => {
if (disposed) stop();
else unlisten = stop;
});
return () => {
listeners.delete(listener);
disposed = true;
unlisten?.();
};
}
@@ -252,12 +196,10 @@ export async function submitErrorReportBatch(
}
export function markClientErrorEventsSubmitted(events: ClientErrorEvent[]) {
for (const event of events) pending.delete(event.fingerprint);
notify();
return ackErrorReports(events.map((event) => event.eventId));
}
/** 仅供单元测试隔离进程内错误池;生产流程不调用。 */
export function resetClientErrorEventsForTests() {
pending.clear();
notify();
// Rust 队列按进程生命周期管理;测试通过 fake bridge 重建进程内状态。
}
@@ -0,0 +1,44 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import type { ClientErrorEvent } from './errorReporting';
type RustErrorReportEvent = ClientErrorEvent & {
lastOccurredAt?: string;
notified?: boolean;
};
export type ErrorReportUpdate = {
generation: number;
newCount: number;
eventIds?: string[];
};
export function reportClientError(input: {
source: string;
message: string;
stack?: string;
action?: string;
page?: string;
}) {
return invoke<RustErrorReportEvent>('report_client_error', input);
}
export function getPendingErrorReports() {
return invoke<RustErrorReportEvent[]>('get_pending_error_reports');
}
export function markErrorReportsNotified(eventIds: string[]) {
return invoke<void>('mark_error_reports_notified', { eventIds });
}
export function ackErrorReports(eventIds: string[]) {
return invoke<void>('ack_error_reports', { eventIds });
}
export function subscribeErrorReportUpdates(
listener: (update: ErrorReportUpdate) => void,
): Promise<UnlistenFn> {
return listen<ErrorReportUpdate>('error-report-updated', (event) => {
listener(event.payload);
});
}
@@ -2,8 +2,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
type FakeErrorEvent = {
eventId: string;
fingerprint: string;
source: string;
message: string;
stack?: string;
occurredAt: string;
count: number;
notified: boolean;
};
const fakeRustQueue = vi.hoisted(() => {
const events = new Map<string, FakeErrorEvent>();
let sequence = 0;
return {
events,
get sequence() {
return sequence;
},
next() {
sequence += 1;
return sequence;
},
reset() {
events.clear();
sequence = 0;
},
};
});
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn().mockResolvedValue(undefined),
invoke: vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command === 'report_client_error') {
const fingerprint = `${args?.source ?? 'client'}|${args?.action ?? ''}|${args?.page ?? ''}|${args?.message ?? ''}`;
const existing = fakeRustQueue.events.get(fingerprint);
if (existing) {
existing.count += 1;
return existing;
}
const event = {
eventId: `rust-error-${fakeRustQueue.next()}`,
fingerprint,
source: String(args?.source ?? 'client'),
message: String(args?.message ?? ''),
stack: typeof args?.stack === 'string' ? args.stack : undefined,
occurredAt: '1',
count: 1,
notified: false,
};
if (fakeRustQueue.events.size >= 100) {
const oldest = fakeRustQueue.events.keys().next().value;
if (oldest) fakeRustQueue.events.delete(oldest);
}
fakeRustQueue.events.set(fingerprint, event);
return event;
}
if (command === 'get_pending_error_reports')
return [...fakeRustQueue.events.values()];
if (command === 'ack_error_reports') {
for (const event of fakeRustQueue.events.values()) {
const eventIds = Array.isArray(args?.eventIds) ? args.eventIds : [];
if (eventIds.includes(event.eventId))
fakeRustQueue.events.delete(event.fingerprint);
}
return undefined;
}
return undefined;
}),
}));
vi.mock('../src/services/clientAuth', () => ({
getStoredAuthAccessToken: vi.fn(() => 'test-token'),
@@ -28,6 +94,7 @@ import {
describe('客户端错误报告池', () => {
afterEach(() => {
fakeRustQueue.reset();
resetClientErrorEventsForTests();
vi.clearAllMocks();
});
@@ -41,15 +108,15 @@ describe('客户端错误报告池', () => {
});
expect(second.eventId).toBe(first.eventId);
expect(getPendingClientErrorEvents()).toHaveLength(1);
expect(getPendingClientErrorEvents()[0]?.count).toBe(2);
expect(await getPendingClientErrorEvents()).toHaveLength(1);
expect((await getPendingClientErrorEvents())[0]?.count).toBe(2);
});
it('限制当前进程错误池最多保留 100 条', async () => {
for (let index = 0; index < 101; index += 1) {
await captureClientError(new Error(`错误 ${index}`), { source: 'test' });
}
expect(getPendingClientErrorEvents()).toHaveLength(100);
expect(await getPendingClientErrorEvents()).toHaveLength(100);
});
it('提交成功后只清除本次提交的事件', async () => {
@@ -66,9 +133,9 @@ describe('客户端错误报告池', () => {
);
await submitErrorReportBatch({ events: [first], logs: [] });
markClientErrorEventsSubmitted([first]);
await markClientErrorEventsSubmitted([first]);
expect(getPendingClientErrorEvents()).toEqual([second]);
expect(await getPendingClientErrorEvents()).toEqual([second]);
});
it('未登录时拒绝提交且不发请求', async () => {
@@ -12,7 +12,8 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。
- Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。
- 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中最新事件,其他事件可勾选。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。
- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只显示一条非阻塞通知;通知支持“查看并报告”和“忽略”,同一 fingerprint 在本次运行中只提醒一次。通知不直接打开阻塞式报告面板
- Rust 是结构化错误队列的唯一真相源:`src-tauri/src/error_report/` 负责脱敏、调用点指纹、eventId、计数、100 条上限、5 秒聚合和 notified/ack 生命周期;WebView 仅通过 Tauri bridge 上报、读取快照并保存短暂 React 展示状态,不维护第二份事件 Map。Rust emit 只携带 generation、newCount 和 eventIds,不携带错误正文
- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只显示一条非阻塞通知;通知支持“查看并报告”和“忽略”,同一 fingerprint 在本次运行中只提醒一次。通知不直接打开阻塞式报告面板。忽略只标记 notified,不删除事件;提交成功后由 bridge ack/delete 选中事件。
- 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。
## HTTP 与存储