重构 AGC 错误报告为 Rust 队列
新增 Rust 内存错误队列、脱敏、通知聚合和 Tauri commands 前端改用 bridge,拆分通知与报告弹窗 UI 补充 Vitest、Rust 单测及错误报告技术方案
This commit is contained in:
+1
@@ -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 与存储
|
||||
|
||||
Reference in New Issue
Block a user