422c8931d6
实现: 在rust内存里维护错误事件队列, webview调用tauri command传入, 后台任务agent工具等直接插入 把rust , webview console的日志统一写到AppData文件夹下的(滚动保存的)日志文件. rust对传入的错误进行筛选,脱敏, 防抖,后通知前端提醒用户. 用户提醒是一个不阻塞的小UI, 展开后可以选择错误上报, 可以附加文字描述 上传时附带最近日志, 错误堆栈等信息 元数据存在数据库, 考虑到字符串信息很难查询, 所以在api server打包成zip存在OSS. 管理页面新增错误报告的查看页面     --------- 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>
445 lines
14 KiB
Rust
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),
|
|
}
|
|
}
|