Files
Genarrative/server-rs/crates/spacetime-module/src/error_report.rs
T
k88936 422c8931d6
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m22s
Project CI / Backend tests (push) Successful in 5m53s
Project CI / Native shell tests (push) Successful in 16m45s
Feat/AGC错误报告 (#240)
实现:
在rust内存里维护错误事件队列, webview调用tauri command传入, 后台任务agent工具等直接插入
把rust , webview console的日志统一写到AppData文件夹下的(滚动保存的)日志文件.
rust对传入的错误进行筛选,脱敏, 防抖,后通知前端提醒用户.
用户提醒是一个不阻塞的小UI, 展开后可以选择错误上报, 可以附加文字描述
上传时附带最近日志, 错误堆栈等信息

元数据存在数据库, 考虑到字符串信息很难查询, 所以在api server打包成zip存在OSS.
管理页面新增错误报告的查看页面

![shotmd-1788328504.jpg](/attachments/641b43c8-deed-47d8-8c78-36adb9a2548f)
![shotmd-1788328495.jpg](/attachments/33c6ada3-dd4d-44b6-9859-f18f944eb653)
![shotmd-1788328222.jpg](/attachments/59267df9-f624-4e47-bb9b-b69d77f595c4)
![shotmd-1788328230.jpg](/attachments/63ea6a86-9a51-412b-a98c-dce454bcc968)

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/240
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-03 10:02:05 +08:00

445 lines
14 KiB
Rust

use crate::*;
const MAX_FIELD_CHARS: usize = 512;
const MAX_NOTE_CHARS: usize = 2_000;
const MAX_ARCHIVE_SIZE_BYTES: u64 = 20 * 1024 * 1024;
const MAX_EVENT_COUNT: u32 = 100;
const MAX_LOG_COUNT: u32 = 5;
const MAX_REPORTS_PER_IDENTITY_PER_HOUR: usize = 100;
const REPORT_QUOTA_WINDOW_MICROS: i64 = 60 * 60 * 1_000_000;
#[spacetimedb::table(
accessor = error_report,
index(accessor = by_error_report_user_submission, btree(columns = [user_id, submission_id])),
index(accessor = by_error_report_created_at, btree(columns = [created_at])),
index(accessor = by_error_report_review_status, btree(columns = [review_status]))
)]
#[derive(Clone)]
pub struct ErrorReport {
#[primary_key]
pub batch_id: String,
pub user_id: String,
pub submission_id: String,
#[unique]
pub idempotency_key: String,
pub object_key: String,
pub archive_sha256: String,
pub archive_size_bytes: u64,
pub event_count: u32,
pub log_count: u32,
pub first_fingerprint: Option<String>,
pub first_source: Option<String>,
pub review_status: String,
pub admin_note: Option<String>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ErrorReportCreateInput {
pub batch_id: String,
pub submission_id: String,
pub idempotency_key: String,
pub user_id: String,
pub object_key: String,
pub archive_sha256: String,
pub archive_size_bytes: u64,
pub event_count: u32,
pub log_count: u32,
pub first_fingerprint: Option<String>,
pub first_source: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ErrorReportListInput {
pub status: Option<String>,
pub fingerprint: Option<String>,
pub source: Option<String>,
pub limit: u32,
pub offset: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ErrorReportGetInput {
pub batch_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ErrorReportUpdateInput {
pub batch_id: String,
pub status: String,
pub note: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ErrorReportDeleteInput {
pub batch_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ErrorReportSnapshot {
pub batch_id: String,
pub user_id: String,
pub submission_id: String,
pub idempotency_key: String,
pub object_key: String,
pub archive_sha256: String,
pub archive_size_bytes: u64,
pub event_count: u32,
pub log_count: u32,
pub first_fingerprint: Option<String>,
pub first_source: Option<String>,
pub review_status: String,
pub admin_note: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ErrorReportProcedureResult {
pub ok: bool,
pub report: Option<ErrorReportSnapshot>,
pub reports: Vec<ErrorReportSnapshot>,
pub total: u64,
pub error_message: Option<String>,
}
fn error(message: impl Into<String>) -> ErrorReportProcedureResult {
ErrorReportProcedureResult {
ok: false,
report: None,
reports: Vec::new(),
total: 0,
error_message: Some(message.into()),
}
}
fn validate_text(value: &str, field: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value.chars().count() > MAX_FIELD_CHARS {
return Err(format!("error_report.{field} 无效"));
}
Ok(value.to_string())
}
fn snapshot(row: &ErrorReport) -> ErrorReportSnapshot {
ErrorReportSnapshot {
batch_id: row.batch_id.clone(),
user_id: row.user_id.clone(),
submission_id: row.submission_id.clone(),
idempotency_key: row.idempotency_key.clone(),
object_key: row.object_key.clone(),
archive_sha256: row.archive_sha256.clone(),
archive_size_bytes: row.archive_size_bytes,
event_count: row.event_count,
log_count: row.log_count,
first_fingerprint: row.first_fingerprint.clone(),
first_source: row.first_source.clone(),
review_status: row.review_status.clone(),
admin_note: row.admin_note.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
#[spacetimedb::procedure]
pub fn create_error_report_and_return(
ctx: &mut ProcedureContext,
input: ErrorReportCreateInput,
) -> ErrorReportProcedureResult {
let caller = ctx.sender();
let now = ctx.timestamp;
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let batch_id = validate_text(&input.batch_id, "batch_id")?;
let user_id = validate_text(&input.user_id, "user_id")?;
let submission_id = validate_text(&input.submission_id, "submission_id")?;
let idempotency_key = validate_text(&input.idempotency_key, "idempotency_key")?;
let object_key = validate_text(&input.object_key, "object_key")?;
if object_key != format!("agc/error-reports/v1/{batch_id}.zip") {
return Err("error_report.object_key 格式无效".to_string());
}
let archive_sha256 = validate_text(&input.archive_sha256, "archive_sha256")?;
if archive_sha256.len() != 64
|| !archive_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err("error_report.archive_sha256 必须是 64 位十六进制 SHA-256".to_string());
}
if input.archive_size_bytes == 0 || input.archive_size_bytes > MAX_ARCHIVE_SIZE_BYTES {
return Err("error_report.archive_size_bytes 超出上限".to_string());
}
if input.event_count == 0 || input.event_count > MAX_EVENT_COUNT {
return Err("error_report.event_count 超出上限".to_string());
}
if input.log_count > MAX_LOG_COUNT {
return Err("error_report.log_count 超出上限".to_string());
}
if let Some(existing) = tx
.db
.error_report()
.by_error_report_user_submission()
.filter((user_id.as_str(), submission_id.as_str()))
.next()
{
return Ok(snapshot(&existing));
}
if tx
.db
.error_report()
.idempotency_key()
.find(&idempotency_key)
.is_some()
{
return Err("错误报告提交幂等键冲突".to_string());
}
if tx.db.error_report().batch_id().find(&batch_id).is_some() {
return Err("错误报告 batch_id 已存在".to_string());
}
let cutoff_micros = now
.to_micros_since_unix_epoch()
.saturating_sub(REPORT_QUOTA_WINDOW_MICROS);
let recent_count = tx
.db
.error_report()
.iter()
.filter(|row| {
row.user_id == user_id
&& row.created_at.to_micros_since_unix_epoch() >= cutoff_micros
})
.count();
if recent_count >= MAX_REPORTS_PER_IDENTITY_PER_HOUR {
return Err("错误报告提交频率超出限制,请稍后再试".to_string());
}
tx.db.error_report().insert(ErrorReport {
batch_id: batch_id.clone(),
user_id,
submission_id,
idempotency_key,
object_key,
archive_sha256,
archive_size_bytes: input.archive_size_bytes,
event_count: input.event_count,
log_count: input.log_count,
first_fingerprint: input
.first_fingerprint
.as_deref()
.map(|value| validate_text(value, "first_fingerprint"))
.transpose()?,
first_source: input
.first_source
.as_deref()
.map(|value| validate_text(value, "first_source"))
.transpose()?,
review_status: "new".to_string(),
admin_note: None,
created_at: now,
updated_at: now,
});
tx.db
.error_report()
.batch_id()
.find(&batch_id)
.map(|row| snapshot(&row))
.ok_or_else(|| "错误报告写入后读取失败".to_string())
}) {
Ok(report) => ErrorReportProcedureResult {
ok: true,
report: Some(report),
reports: Vec::new(),
total: 1,
error_message: None,
},
Err(message) => error(message),
}
}
#[spacetimedb::procedure]
pub fn get_error_report_and_return(
ctx: &mut ProcedureContext,
input: ErrorReportGetInput,
) -> ErrorReportProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
tx.db
.error_report()
.batch_id()
.find(&input.batch_id)
.map(|row| snapshot(&row))
.ok_or_else(|| "错误报告不存在".to_string())
}) {
Ok(report) => ErrorReportProcedureResult {
ok: true,
report: Some(report),
reports: Vec::new(),
total: 1,
error_message: None,
},
Err(message) => error(message),
}
}
#[spacetimedb::procedure]
pub fn list_error_reports_and_return(
ctx: &mut ProcedureContext,
input: ErrorReportListInput,
) -> ErrorReportProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let limit = input.limit.clamp(1, 500) as usize;
let offset = input.offset as usize;
if input.status.is_none() && input.fingerprint.is_none() && input.source.is_none() {
let mut rows = tx
.db
.error_report()
.by_error_report_created_at()
.filter(Timestamp::from_micros_since_unix_epoch(i64::MIN)..)
.collect::<Vec<_>>();
let total = rows.len() as u64;
rows.reverse();
let reports = rows
.into_iter()
.skip(offset)
.take(limit)
.map(|row| snapshot(&row))
.collect::<Vec<_>>();
return Ok((reports, total));
}
let mut rows = tx
.db
.error_report()
.iter()
.filter(|row| {
input
.status
.as_deref()
.is_none_or(|v| v == row.review_status)
})
.filter(|row| {
input
.fingerprint
.as_deref()
.is_none_or(|v| row.first_fingerprint.as_deref() == Some(v))
})
.filter(|row| {
input
.source
.as_deref()
.is_none_or(|v| row.first_source.as_deref() == Some(v))
})
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| right.batch_id.cmp(&left.batch_id))
});
let total = rows.len() as u64;
let reports = rows
.into_iter()
.skip(offset)
.take(limit)
.map(|row| snapshot(&row))
.collect::<Vec<_>>();
Ok::<(Vec<ErrorReportSnapshot>, u64), String>((reports, total))
}) {
Ok((reports, total)) => ErrorReportProcedureResult {
ok: true,
report: None,
reports,
total,
error_message: None,
},
Err(message) => error(message),
}
}
#[spacetimedb::procedure]
pub fn update_error_report_and_return(
ctx: &mut ProcedureContext,
input: ErrorReportUpdateInput,
) -> ErrorReportProcedureResult {
let caller = ctx.sender();
let now = ctx.timestamp;
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
if !matches!(input.status.as_str(), "new" | "in-progress" | "resolved") {
return Err("状态必须是 new、in-progress 或 resolved".to_string());
}
let row = tx
.db
.error_report()
.batch_id()
.find(&input.batch_id)
.ok_or_else(|| "错误报告不存在".to_string())?;
let note = input
.note
.clone()
.map(|value| {
if value.chars().count() > MAX_NOTE_CHARS {
return Err("error_report.note 超过 2000 个字符".to_string());
}
Ok(value)
})
.transpose()?;
let updated = ErrorReport {
review_status: input.status.clone(),
admin_note: note,
updated_at: now,
..row.clone()
};
tx.db.error_report().batch_id().delete(&row.batch_id);
tx.db.error_report().insert(updated.clone());
Ok(snapshot(&updated))
}) {
Ok(report) => ErrorReportProcedureResult {
ok: true,
report: Some(report),
reports: Vec::new(),
total: 1,
error_message: None,
},
Err(message) => error(message),
}
}
#[spacetimedb::procedure]
pub fn delete_error_report_and_return(
ctx: &mut ProcedureContext,
input: ErrorReportDeleteInput,
) -> ErrorReportProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let row = tx
.db
.error_report()
.batch_id()
.find(&input.batch_id)
.ok_or_else(|| "错误报告不存在".to_string())?;
let snapshot = snapshot(&row);
tx.db.error_report().batch_id().delete(&row.batch_id);
Ok::<ErrorReportSnapshot, String>(snapshot)
}) {
Ok(report) => ErrorReportProcedureResult {
ok: true,
report: Some(report),
reports: Vec::new(),
total: 1,
error_message: None,
},
Err(message) => error(message),
}
}